From b54c0dacd06f6f56577ee4d48d2dc9e98264e5aa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:49:03 +0900 Subject: [PATCH 01/81] build!: require iOS 18.4 and macOS 15.4 --- ContractTests/Package.swift | 4 ++-- Monocly/Monocly.xcodeproj/project.pbxproj | 8 ++++---- Package.swift | 2 +- Packages/WebInspectorNativeBridge/Package.swift | 2 +- README.md | 3 ++- Scripts/preview-docc.py | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ContractTests/Package.swift b/ContractTests/Package.swift index 5859fe6f..1aa9be7b 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: ".."), 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/Package.swift b/Package.swift index c214a0a0..15529465 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( 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..620b70c6 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. 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", From 4841768093dd1676788d2ecbcc1858da6fb37933 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:37:29 +0900 Subject: [PATCH 02/81] docs(architecture): define inspector kit ownership --- Docs/WebInspectorKitsArchitecture.md | 1616 ++++++++++++++++++++++++++ 1 file changed, 1616 insertions(+) create mode 100644 Docs/WebInspectorKitsArchitecture.md diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md new file mode 100644 index 00000000..fc64d556 --- /dev/null +++ b/Docs/WebInspectorKitsArchitecture.md @@ -0,0 +1,1616 @@ +# WebInspectorProxyKit and WebInspectorDataKit Architecture + +- Status: proposed design gate +- 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 statically `@MainActor` session facade over stable + identity models and snapshot-plus-diff query results. 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` / arbitrary-actor `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 session, 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 session; + - 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. +- 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`; executor scheduling + and escaping-self behavior in the concurrency runtime/tests. +- WebKit `9d2c43b4dc9d9c47448c510c87e79ecaf40b60a4`: enable-time replay in Runtime + protocol and Console/Network/CSS/Inspector agents, plus provisional target + commit ordering in `WebPageInspectorController.cpp`. +- 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. + +## 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 session owns an awaited transition and only starts +configured capabilities plus their declared dependencies. + +### P0: arbitrary actor ownership prevents static lifecycle ownership + +`WebInspectorContext` stores `any Actor`, dynamically checks it throughout the +implementation, and centralizes unrelated domain state in one type. Production +consumers are main-actor UIKit code; the only custom-actor consumer is a +contract test. The dynamic isolation prevents an `isolated deinit` and makes +every API repeat an ownership token. + +Owner correction: the session, identity models, and result owners are statically +`@MainActor`; heavy query projection remains behind internal actors. A future +headless/background product, if required by a real consumer, will expose +Sendable snapshots from a separate actor store rather than dynamically isolating +the same observable objects. + +### 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. `WebInspectorModelSession` 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. ProxyKitTesting is +documented for DataKit consumers but requires those consumers to script six raw +startup replies and busy-poll private readiness. + +Owner correction: UIKit keeps its presentation-only `WebInspectorSession`, +which publicly exposes one DataKit `WebInspectorModelSession` 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. A separate +DataKitTesting product provides model-level scenarios. + +## 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 `WebInspectorModelSession` 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 scripted backend | internal connection-core backend boundary | domain clients and DataKit models | +| physical target replacement | core target/capability registries and ordered generation boundary | public page handle and UIKit controllers | +| selected DataKit domains | session 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 session 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 and injected test clock/gates | public protocol DTOs | + +The production connection initializer creates the native backend. Internal +initializers inject a backend and clock for tests. They are concrete package +boundaries, not public 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 | `@MainActor WebInspectorModelSession` | attach, detach, close, ordered binding reset | +| DOM identity, tree, selection, edits | package-internal `DOMStateStore` | DOM events and DOM commands | +| Network identity registry and query membership | package-internal `NetworkRequestStore` plus its off-main-actor index | Network events and clear/load operations | +| Console identity registry and query membership | 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 | +| CSS state and mutation | package-internal `CSSStateStore` | CSS events and awaited CSS commands | +| 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. `WebInspectorProxy` and +`WebInspectorPage` are Sendable handles that strongly retain it; the core never +retains either handle. Keeping any handle keeps the connection usable without a +cycle. + +### 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 session follows a main-actor observable document/model container: + stable model-owner identities publish semantic changes, while UI controllers + keep presentation state only. +- 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.Client { get } + public var css: CSS.Client { get } + public var network: Network.Client { get } + public var console: Console.Client { get } + public var runtime: Runtime.Client { get } + public var page: Page.Client { 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.Client { + 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, Console, and Runtime expose the same structured event scope. 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 session 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, domain, and a replay-complete boundary derived from the enable +reply watermark: + +```swift +package enum ConnectionModelFeedRecord: Sendable { + case reset(WebInspectorPage.Generation) + 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 + ) +} + +package enum ModelProtocolEvent: Sendable { /* typed domain payloads */ } +package enum ModelBootstrapSnapshot: Sendable { /* typed snapshots */ } +package enum ModelDomain: Hashable, Sendable { /* configured domains */ } +``` + +One DataKit feed consumer applies 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. +The feed is package-only because its mixed-domain payload and acknowledgement +boundary are model-adapter mechanics, not a direct ProxyKit consumer concept. +Opening the feed registers it and its initial reset record before acquiring any +configured capability. Capability acquisition is transactional: if one enable +fails, the core releases every capability acquired for that attempt, terminates +the feed with the cause, and lets DataKit fail attachment. A DataKit model +session creates and exclusively owns 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`. 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 is followed by `bootstrapComplete`. +Every configured domain must reach either its replay or bootstrap completion +boundary before attachment or retarget synchronization is ready. A later +`DOM.documentUpdated` resets the DOM/CSS epochs and starts the same ordered +snapshot bootstrap again rather than exposing an empty tree as ready state. + +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 +``` + +Element picking becomes a dedicated scoped operation rather than a Boolean +`DOM.setInspectMode` that cannot represent multiple users: + +```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 one ordered transition: + +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-complete watermark; +6. 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 also bounded. Overflow terminates the model + attachment and moves its session to `.failed`; it cannot fabricate 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 remains explicit opt-in, never the default. + +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 `WebInspectorModelSession` as the semantic model owner. The +UIKit `WebInspectorSession` remains in WebInspectorUI and stores exactly one +model session plus UI-owned state described below. + +```swift +public enum WebInspectorModelError: Error, Equatable, Sendable { + case detached + case synchronizing + case domainNotConfigured(WebInspectorModelSession.Domain) + case staleModel + case commandRejected(method: String, message: String) +} + +@MainActor +@Observable +public final class WebInspectorModelSession { + public struct PageGeneration: Hashable, Sendable { /* opaque */ } + + public struct Configuration: Sendable { + public let domains: Set + public init(domains: Set = Set(Domain.allCases)) + } + + public enum Domain: Hashable, Sendable, CaseIterable { + case dom, network, console, runtime, css + } + + 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) + case eventBufferOverflow(domain: Domain) + } + + 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()) + public func attach(to webView: WKWebView) async throws + public func detach() async + public func close() async + + public static func withAttachment( + to webView: WKWebView, + configuration: Configuration = .init(), + _ operation: @MainActor (WebInspectorModelSession) async throws -> Result + ) async throws -> Result + + public func reload(ignoringCache: Bool = false) async throws + + public func domNode(id: DOMNode.ID) throws -> DOMNode? + public func domTree(rootedAt node: DOMNode) throws -> DOMTreeController + public func requestDOMChildren( + of node: DOMNode, + depth: Int = 1 + ) async throws + public func selectDOMNode( + _ node: DOMNode?, + reveal: DOMRevealPolicy = .selectAndScroll + ) throws + public 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 func setDOMAttribute( + _ name: String, + value: String, + on node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome + public func setOuterHTML( + _ html: String, + of node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome + public func removeDOMNodes( + _ nodes: [DOMNode], + undo: WebInspectorUndoPolicy = .automatic + ) async throws + -> DOMMutationOutcome + public func highlightDOMNode(_ node: DOMNode) async throws + public func hideDOMHighlight() async throws + public func setElementPickerEnabled(_ enabled: Bool) async throws + + public func networkRequests(matching query: NetworkQuery = .init()) + async throws -> WebInspectorFetchedResults + public func consoleMessages(matching query: ConsoleQuery = .init()) + async throws -> WebInspectorFetchedResults + public func networkRequest(id: NetworkRequest.ID) throws -> NetworkRequest? + public func clearNetworkRequests() throws + public func clearConsoleMessages() async throws + public func responseBody(for request: NetworkRequest) async throws + -> NetworkBody + + public func withRuntimeObjectGroup( + named: String? = nil, + _ operation: @MainActor (RuntimeObjectGroup) async throws -> Result + ) async throws -> Result + + public func cssStyles(for node: DOMNode) async throws -> CSSStyles + public func refreshCSSStyles(for node: DOMNode) async throws + public func setCSSProperty( + _ property: CSSStyleProperty, + enabled: Bool, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public func setCSSDeclarationText( + _ text: String, + for property: CSSStyleProperty, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public func setCSSRuleSelector( + _ selector: String, + for rule: CSSStyleRule, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public func setCSSStyleSheetText( + _ text: String, + for styleSheetID: CSS.StyleSheet.ID, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? +} +``` + +`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. Attaching the same view 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; +same-view attachment during that interval joins the current synchronization +instead of returning early. Attaching an already ready same view returns +immediately. Attaching a different view supersedes the old transition, makes its +waiters throw `TransitionError.superseded`, awaits complete teardown, and then +begins the new attachment. `detach()` supersedes any in-flight attach, is +idempotent, and leaves the session reusable. Retry is allowed from `.failed` +after teardown. `close()` is idempotent and terminal. + +An attach waiter does not own the shared session resource: cancelling one caller +cancels only that wait, not an attachment another UI host may be using. Explicit +`detach()`/`close()` changes resource state. Static `withAttachment` always +creates a fresh session, closes it on every exit path, and therefore cannot be +nested on an already attached instance. Every transition is serialized by one +state machine despite MainActor reentrancy. Failure is carried only by +`State.failed`; there is no separately mutable optional error. + +The session stores one feed-driver task. Its closure captures the session weakly, +holds a strong reference only while applying one record, and releases that +reference before awaiting the next record. `detach()` and `close()` cancel and +await the driver. This prevents `session -> task -> session` from making +isolated deinitialization unreachable. + +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. + +One session-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 | + +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 session'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; `CSSStateStore` owns node-style resources, stale marking, and + awaited mutation serialization. + +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. `WebInspectorModelSession` 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 statically `@MainActor`. Only its immutable Sendable +snapshot/delta/record values cross to index or transport actors. + +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. + +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. + +```swift +public struct DOMMutationFailure: Error, Hashable, Sendable { + public let nodeID: DOMNode.ID + public let message: String +} + +@MainActor +public struct DOMMutationOutcome { + public let requestedNodeIDs: [DOMNode.ID] + public let appliedNodeIDs: [DOMNode.ID] + public let failures: [DOMMutationFailure] + public let undo: DOMUndoCapability? +} + +@MainActor +public final class DOMUndoCapability { + public func undo() async throws + public func redo() async throws +} + +@MainActor +public final class RuntimeObjectGroup { + public func evaluate( + _ expression: String, + in context: RuntimeContext? = nil + ) async throws -> RuntimeEvaluation + + public func properties( + of object: RuntimeObject, + ownProperties: Bool = true + ) async throws -> [RuntimeProperty] + + public func preview(of object: RuntimeObject) async throws + -> RuntimeObjectPreview + public func close() async throws +} + +@MainActor +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 sort: NetworkSort + public var offset: Int + public var limit: Int? + + public init( + search: String? = nil, + resourceCategories: Set? = nil, + sort: NetworkSort = .requestTimeDescending, + offset: Int = 0, + limit: Int? = nil + ) +} + +public enum NetworkSort: Sendable, Equatable { + case requestTimeAscending + case requestTimeDescending +} + +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 + ) +} + +@MainActor +@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 } + func update(_ query: NetworkQuery) async throws +} + +public extension WebInspectorFetchedResults where Model == ConsoleMessage { + var query: ConsoleQuery { get } + func update(_ query: ConsoleQuery) async throws +} +``` + +`ConsoleQuery` follows the same closed pattern with supported message filters, +ordering, and grouping. `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 MainActor 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. Coalescing may +replace an unconsumed `.initial`, but the surviving transaction is itself a +complete current snapshot. 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 session alive. +The zero-state `WebInspectorFetchedResultsController` forwarding wrapper is +deleted; the snapshot, transaction, and result types are retained. + +Concretely, when `Continuation.yield(new)` reports `.dropped(old)`, the broker +forms `merged` with `old.reconfigureItemIDs ∪ new.reconfigureItemIDs` and the +newest revision/snapshot, then yields that replacement once; the just-enqueued +unmerged value is the element displaced. The resulting revision gap tells the +consumer to use the full newest snapshot, while the union preserves all cell +reconfiguration work. + +The existing `NetworkRequestIndex` actor remains the off-MainActor query/diff +owner and is improved rather than replaced. It consumes compact Sendable record +changes, maintains registered query membership/order, and returns identity +snapshots and deltas; query replacement may perform a full scan on that actor, +but a live insert must not dereference or filter every model on MainActor. +The facade's result-creation methods are `async` so their first filtered/sorted +snapshot is complete before return without doing that scan on MainActor. +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. + +### 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). + +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 `@MainActor @Observable`; ordered collection +topology uses the retained DOM/fetched-results snapshot-plus-delta contracts. +UIKit observes identities through ObservationBridge and consumes topology +updates into diffable/native snapshots. 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: WebInspectorModelSession + 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 +delegates attachment to `model` 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. + +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. + +Because a tab factory may await an off-MainActor initial query, the root also +owns one content-loading state machine per tab: `idle`, `loading`, `ready`, or +`failed`. Synchronous UIKit selection/layout installs a loading placeholder; +one weak-root task invokes the `@MainActor async throws` factory. Concurrent +compact/regular requests for the same tab join that task and only one controller +is cached. A normal tab switch keeps the in-flight load/cache for reuse; +asynchronous descriptor removal or explicit root retirement/close cancels and +awaits the affected loads before clearing their cache entries. A factory failure +installs a retryable error controller rather than failing model attachment. The +task may retain the UI session, but never the root; isolated root +deinitialization synchronously cancels any remaining task as a backstop while +explicit 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 both sessions, controllers, and fetched-results owner +identities while resetting only models/resources whose owning epoch changed. + +### 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: attach success is model readiness, and the query is domain-specific. +let session = WebInspectorModelSession( + configuration: .init(domains: [.network]) +) +try await session.attach(to: webView) +let requests = try await session.networkRequests( + matching: NetworkQuery(sort: .requestTimeDescending) +) + +for await update in requests.updates() { + apply(update) // initial full snapshot, then self-contained deltas +} +``` + +## 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 tasks, finish local + continuations/streams, and assert or complete synchronous terminal state. +- Other non-MainActor actor resources introduced by the final implementation, + such as a test runtime core: synchronously terminate their local waiters. +- `@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. +- `@MainActor` DataKit owners: cancel locally stored tasks and invalidate local + observation state. + +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 dynamic `any Actor` +isolation precisely because it cannot express this ownership boundary. + +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. Backend callbacks also capture their receiver +weakly. Explicit close cancels and awaits tasks; dropping all public handles can +therefore reach the isolated deinitializer even when close was omitted. No +deinitializer is expected to break a cycle. + +## Access Control + +| Surface | Access | Reason | +| --- | --- | --- | +| Proxy, logical page, typed domain clients/DTOs, structured event scopes | `public` | direct ProxyKit consumer story | +| Physical target records, target registry, routing keys, capability registry | `package` or `internal` | one core owns routing; no external producer story | +| DataKit model session, identity models, existing tree/query/resource types, concrete queries | `public` | custom UI 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 | +| Scriptable Proxy test backend | `public` in ProxyKitTesting | direct consumer contract | +| 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. + +## 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`, arbitrary-actor `WebInspectorContext`, EventPump, + dynamic `requireOwner`, and DataKit `@unchecked Sendable` ownership claims. +- Stateless computed `DOMModelController`, `NetworkModelController`, + `ConsoleModelController`, `RuntimeModelController`, `CSSModelController`, + `PageModelController`, and forwarding `WebInspectorEditHistory` wrappers. +- Duplicate direct Context/model convenience operations after their + `WebInspectorModelSession` facade operation or scoped-resource equivalent + exists. +- `WebInspectorFetchableModel`, `WebInspectorPersistentModel`, 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 DataKit model session plus genuine + presentation state. +- Fire-and-forget CSS mutations and hidden partial-delete errors. +- 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 session 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 background arbitrary-actor option on the same graph:** restores dynamic + checks and makes Observation callbacks executor-ambiguous. +- **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, and replay-complete markers + preserve transport ordering even when an old-generation consumer is slow. +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 is 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. +12. A capacity-N stream receiving N+1 pending events throws overflow for that + subscriber while a peer continues. +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 handles without explicit close still reaches isolated deinit; weak + references prove connection and DataKit driver tasks do not form cycles. +19. The isolated-deinit fallback synchronously detaches/restores/finishes exactly + once without launching async work. +20. DataKit `attach()` exposes startup failure and does not return before the + configured models are ready. +21. Same-view concurrent attach joins, different-view attach supersedes, caller + cancellation cancels only its wait, detach cancels transition state, a + same-view attach during retarget joins `.synchronizing`, and a failed + session 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. +24. Same-binding navigation resets DOM/CSS and Runtime as their events require + while retaining Network history; physical replacement resets all domains. +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 MainActor. +30. Discarding Network/Console result objects unregisters their weak live-query + sinks and does not retain the model session. +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, descriptor removal, 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 session, 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. + +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 + +- `WebInspectorContext`, `isolated (any Actor)`, and `requireOwner` counts are + zero in DataKit. +- 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, and DOM bootstrap boundaries; DataKit has no independent + per-domain event pumps. +- 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 contains no `@unchecked Sendable` lifecycle owner. +- Stored connection/model tasks weakly reference owners and deallocation tests + prove no owner-task 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 MainActor full-model + evaluation on live insert. +- DOM/CSS, Runtime, Network, and Console stale validation uses their documented + domain epochs rather than one session-wide generation. +- 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. +- 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 session and model contracts` +7. `refactor(data)!: replace container and context with model session` +8. `refactor(data)!: move DOM and CSS state to domain owners` +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 session 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 + +Implementation begins only after explicit approval of this document. Approval +accepts the breaking removals, the MainActor-owned DataKit session/identity +state, retained snapshot-plus-delta tree and fetched-results contracts, no new +public per-domain Model/Store facades, the split between DataKit's +`WebInspectorModelSession` and UIKit's presentation-only `WebInspectorSession`, +structured ProxyKit event scopes, and the explicit-close plus isolated-deinit +lifecycle contract. From 31d0617c92cc08a28dcd9311d1b550cd5a1c7c84 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:23:38 +0900 Subject: [PATCH 03/81] docs(architecture): inherit model context isolation --- Docs/WebInspectorKitsArchitecture.md | 475 +++++++++++++++++---------- 1 file changed, 303 insertions(+), 172 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index fc64d556..d23731ce 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -21,16 +21,19 @@ The redesign has four governing invariants: 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 statically `@MainActor` session facade over stable - identity models and snapshot-plus-diff query results. A successful - `attach(to:)` means the configured state is ready to use. +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` / arbitrary-actor `WebInspectorContext`, generic fetch, -and duplicate domain-controller APIs. +`WebInspectorContainer` / dynamically checked `WebInspectorContext`, generic +fetch, and duplicate domain-controller APIs. ## Scope Contract @@ -39,7 +42,7 @@ and duplicate domain-controller APIs. - 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 session, observe readiness and failure, +- 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 @@ -54,7 +57,7 @@ and duplicate domain-controller APIs. 1. The built-in UIKit inspector and Monocly integration. 2. Public-only contract consumers: - - a custom Console tab receiving a DataKit session; + - 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 @@ -122,14 +125,28 @@ 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`; executor scheduling - and escaping-self behavior in the concurrency runtime/tests. + `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`. - 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 a non-Sendable context stored by one actor is +rejected when sent to another; `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. The last limitation 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 @@ -189,22 +206,26 @@ 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 session owns an awaited transition and only starts +Owner correction: the DataKit context owns an awaited transition and only starts configured capabilities plus their declared dependencies. -### P0: arbitrary actor ownership prevents static lifecycle ownership +### P0: actor ownership is represented by a retained token and runtime checks -`WebInspectorContext` stores `any Actor`, dynamically checks it throughout the -implementation, and centralizes unrelated domain state in one type. Production -consumers are main-actor UIKit code; the only custom-actor consumer is a -contract test. The dynamic isolation prevents an `isolated deinit` and makes -every API repeat an ownership token. +`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 session, identity models, and result owners are statically -`@MainActor`; heavy query projection remains behind internal actors. A future -headless/background product, if required by a real consumer, will expose -Sendable snapshots from a separate actor store rather than dynamically isolating -the same observable objects. +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 @@ -265,7 +286,7 @@ 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. `WebInspectorModelSession` is the public facade, +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. @@ -290,7 +311,7 @@ documented for DataKit consumers but requires those consumers to script six raw startup replies and busy-poll private readiness. Owner correction: UIKit keeps its presentation-only `WebInspectorSession`, -which publicly exposes one DataKit `WebInspectorModelSession` as `model` and is +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. A separate DataKitTesting product provides model-level scenarios. @@ -320,12 +341,12 @@ 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 `WebInspectorModelSession` 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. +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 @@ -333,9 +354,9 @@ state in DataKit and UI-specific state in WebInspectorUI. | --- | --- | --- | | live native bridge versus scripted backend | internal connection-core backend boundary | domain clients and DataKit models | | physical target replacement | core target/capability registries and ordered generation boundary | public page handle and UIKit controllers | -| selected DataKit domains | session configuration and capability dependency table | unrelated store startup branches | +| 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 session transition state machine | per-domain ad hoc flags | +| 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 and injected test clock/gates | public protocol DTOs | @@ -356,7 +377,7 @@ promise. | 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 | `@MainActor WebInspectorModelSession` | attach, detach, close, ordered binding reset | +| model attachment and physical binding generations | caller-confined `WebInspectorModelContext` | owning actor via attach, detach, close, and ordered feed application | | DOM identity, tree, selection, edits | package-internal `DOMStateStore` | DOM events and DOM commands | | Network identity registry and query membership | package-internal `NetworkRequestStore` plus its off-main-actor index | Network events and clear/load operations | | Console identity registry and query membership | package-internal `ConsoleMessageStore` plus its off-main-actor index | Console events and clear operations | @@ -392,9 +413,10 @@ the bridge, releases the token, and only then resumes close waiters. - 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 session follows a main-actor observable document/model container: - stable model-owner identities publish semantic changes, while UI controllers - keep presentation state only. +- 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. @@ -483,7 +505,7 @@ 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 session or domain owners. +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 @@ -555,7 +577,7 @@ Opening the feed registers it and its initial reset record before acquiring any configured capability. Capability acquisition is transactional: if one enable fails, the core releases every capability acquired for that attempt, terminates the feed with the cause, and lets DataKit fail attachment. A DataKit model -session creates and exclusively owns its Proxy connection, so it is always the +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. @@ -667,7 +689,7 @@ public enum WebInspectorProxyError: Error, Sendable { terminates only that subscriber with `eventBufferOverflow`; peer subscribers continue. - The package model feed is also bounded. Overflow terminates the model - attachment and moves its session to `.failed`; it cannot fabricate a full +attachment and moves its context to `.failed`; it cannot fabricate a full domain resynchronization. - Coalescible state notifications use newest-one buffering. - Unexpected disconnect throws from event scopes. Explicit close ends scopes @@ -705,22 +727,23 @@ try await proxy.page.network.withEvents { events in ## DataKit API Sketch -DataKit introduces `WebInspectorModelSession` as the semantic model owner. The -UIKit `WebInspectorSession` remains in WebInspectorUI and stores exactly one -model session plus UI-owned state described below. +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(WebInspectorModelSession.Domain) + case domainNotConfigured(WebInspectorModelContext.Domain) case staleModel case commandRejected(method: String, message: String) } -@MainActor @Observable -public final class WebInspectorModelSession { +public final class WebInspectorModelContext { public struct PageGeneration: Hashable, Sendable { /* opaque */ } public struct Configuration: Sendable { @@ -772,21 +795,28 @@ public final class WebInspectorModelSession { public var runtimeContexts: [RuntimeContext] { get throws } public init(configuration: Configuration = .init()) - public func attach(to webView: WKWebView) async throws - public func detach() async - public func close() async - public static func withAttachment( - to webView: WKWebView, + @MainActor + public convenience init( + attachingTo webView: WKWebView, configuration: Configuration = .init(), - _ operation: @MainActor (WebInspectorModelSession) async throws -> Result - ) async throws -> Result + proxyConfiguration: WebInspectorProxy.Configuration = .init() + ) async throws - public func reload(ignoringCache: Bool = false) 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 func requestDOMChildren( + public nonisolated(nonsending) func requestDOMChildren( of node: DOMNode, depth: Int = 1 ) async throws @@ -794,65 +824,79 @@ public final class WebInspectorModelSession { _ node: DOMNode?, reveal: DOMRevealPolicy = .selectAndScroll ) throws - public func copyText( + 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 func setDOMAttribute( + public nonisolated(nonsending) func setDOMAttribute( _ name: String, value: String, on node: DOMNode, undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMMutationOutcome - public func setOuterHTML( + public nonisolated(nonsending) func setOuterHTML( _ html: String, of node: DOMNode, undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMMutationOutcome - public func removeDOMNodes( + public nonisolated(nonsending) func removeDOMNodes( _ nodes: [DOMNode], undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMMutationOutcome - public func highlightDOMNode(_ node: DOMNode) async throws - public func hideDOMHighlight() async throws - public func setElementPickerEnabled(_ enabled: Bool) async throws + 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 func networkRequests(matching query: NetworkQuery = .init()) + public nonisolated(nonsending) func networkRequests( + matching query: NetworkQuery = .init() + ) async throws -> WebInspectorFetchedResults - public func consoleMessages(matching query: ConsoleQuery = .init()) + public nonisolated(nonsending) func consoleMessages( + matching query: ConsoleQuery = .init() + ) async throws -> WebInspectorFetchedResults public func networkRequest(id: NetworkRequest.ID) throws -> NetworkRequest? public func clearNetworkRequests() throws - public func clearConsoleMessages() async throws - public func responseBody(for request: NetworkRequest) async throws - -> NetworkBody + public nonisolated(nonsending) func clearConsoleMessages() async throws + public nonisolated(nonsending) func responseBody( + for request: NetworkRequest + ) async throws -> NetworkBody public func withRuntimeObjectGroup( named: String? = nil, - _ operation: @MainActor (RuntimeObjectGroup) async throws -> Result + isolation: isolated (any Actor) = #isolation, + _ operation: (RuntimeObjectGroup) async throws -> Result ) async throws -> Result - public func cssStyles(for node: DOMNode) async throws -> CSSStyles - public func refreshCSSStyles(for node: DOMNode) async throws - public func setCSSProperty( + 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 func setCSSDeclarationText( + public nonisolated(nonsending) func setCSSDeclarationText( _ text: String, for property: CSSStyleProperty, undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMUndoCapability? - public func setCSSRuleSelector( + public nonisolated(nonsending) func setCSSRuleSelector( _ selector: String, for rule: CSSStyleRule, undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMUndoCapability? - public func setCSSStyleSheetText( + public nonisolated(nonsending) func setCSSStyleSheetText( _ text: String, for styleSheetID: CSS.StyleSheet.ID, undo: WebInspectorUndoPolicy = .automatic @@ -862,30 +906,51 @@ public final class WebInspectorModelSession { `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. Attaching the same view while an +replay or bootstrap completion boundary. 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; -same-view attachment during that interval joins the current synchronization -instead of returning early. Attaching an already ready same view returns -immediately. Attaching a different view supersedes the old transition, makes its -waiters throw `TransitionError.superseded`, awaits complete teardown, and then -begins the new attachment. `detach()` supersedes any in-flight attach, is -idempotent, and leaves the session reusable. Retry is allowed from `.failed` -after teardown. `close()` is idempotent and terminal. - -An attach waiter does not own the shared session resource: cancelling one caller -cancels only that wait, not an attachment another UI host may be using. Explicit -`detach()`/`close()` changes resource state. Static `withAttachment` always -creates a fresh session, closes it on every exit path, and therefore cannot be -nested on an already attached instance. Every transition is serialized by one -state machine despite MainActor reentrancy. Failure is carried only by -`State.failed`; there is no separately mutable optional error. - -The session stores one feed-driver task. Its closure captures the session weakly, -holds a strong reference only while applying one record, and releases that -reference before awaiting the next record. `detach()` and `close()` cancel and -await the driver. This prevents `session -> task -> session` from making -isolated deinitialization unreachable. +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 @@ -898,7 +963,7 @@ 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. -One session-wide generation would be too coarse. Each model owns the epoch that +One context-wide generation would be too coarse. Each model owns the epoch that defines its identities: | Lifetime boundary | Affected owners | @@ -914,7 +979,7 @@ defines its identities: 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 session's attachment counter. +than the context's attachment counter. ### Internal stores and the public facade @@ -939,7 +1004,7 @@ write sets out of `WebInspectorContext`: 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. `WebInspectorModelSession` forwards public +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: @@ -954,8 +1019,11 @@ The existing public resource/result contracts remain the domain surface: Every mutable identity/resource/result class in this table—including `DOMNode`, `DOMTreeController`, `NetworkRequest`, `ConsoleMessage`, and -`CSSStyles`—is statically `@MainActor`. Only its immutable Sendable -snapshot/delta/record values cross to index or transport actors. +`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, @@ -968,6 +1036,11 @@ 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 @@ -991,7 +1064,6 @@ public struct DOMMutationFailure: Error, Hashable, Sendable { public let message: String } -@MainActor public struct DOMMutationOutcome { public let requestedNodeIDs: [DOMNode.ID] public let appliedNodeIDs: [DOMNode.ID] @@ -999,30 +1071,28 @@ public struct DOMMutationOutcome { public let undo: DOMUndoCapability? } -@MainActor public final class DOMUndoCapability { - public func undo() async throws - public func redo() async throws + public nonisolated(nonsending) func undo() async throws + public nonisolated(nonsending) func redo() async throws } -@MainActor public final class RuntimeObjectGroup { - public func evaluate( + public nonisolated(nonsending) func evaluate( _ expression: String, in context: RuntimeContext? = nil ) async throws -> RuntimeEvaluation - public func properties( + public nonisolated(nonsending) func properties( of object: RuntimeObject, ownProperties: Bool = true ) async throws -> [RuntimeProperty] - public func preview(of object: RuntimeObject) async throws - -> RuntimeObjectPreview - public func close() async throws + public nonisolated(nonsending) func preview( + of object: RuntimeObject + ) async throws -> RuntimeObjectPreview + public nonisolated(nonsending) func close() async throws } -@MainActor public struct WebInspectorRuntimeScopeError: Error { public let operationError: any Error public let cleanupError: any Error @@ -1086,7 +1156,6 @@ public enum WebInspectorFetchedResultsUpdate< ) } -@MainActor @Observable public final class WebInspectorFetchedResults where Model.ID: Hashable & Sendable { @@ -1102,12 +1171,12 @@ where Model.ID: Hashable & Sendable { public extension WebInspectorFetchedResults where Model == NetworkRequest { var query: NetworkQuery { get } - func update(_ query: NetworkQuery) async throws + nonisolated(nonsending) func update(_ query: NetworkQuery) async throws } public extension WebInspectorFetchedResults where Model == ConsoleMessage { var query: ConsoleQuery { get } - func update(_ query: ConsoleQuery) async throws + nonisolated(nonsending) func update(_ query: ConsoleQuery) async throws } ``` @@ -1121,7 +1190,7 @@ from the deleted `WebInspectorFetchableModel` constraint to `Identifiable` and `ItemID` constraints respectively; their existing section/item delta vocabulary is retained. -`updates()` synchronously registers on MainActor and enqueues the current +`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 @@ -1139,7 +1208,7 @@ 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 session alive. +keeping the model context alive. The zero-state `WebInspectorFetchedResultsController` forwarding wrapper is deleted; the snapshot, transaction, and result types are retained. @@ -1150,13 +1219,14 @@ unmerged value is the element displaced. The resulting revision gap tells the consumer to use the full newest snapshot, while the union preserves all cell reconfiguration work. -The existing `NetworkRequestIndex` actor remains the off-MainActor query/diff +The existing `NetworkRequestIndex` actor remains the off-owner query/diff owner and is improved rather than replaced. It consumes compact Sendable record changes, maintains registered query membership/order, and returns identity snapshots and deltas; query replacement may perform a full scan on that actor, -but a live insert must not dereference or filter every model on MainActor. +but a live insert must not dereference or filter every identity model on its +owner actor. The facade's result-creation methods are `async` so their first filtered/sorted -snapshot is complete before return without doing that scan on MainActor. +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 @@ -1181,12 +1251,16 @@ a remote object. ### Observation and UI flow -Semantic identity state is `@MainActor @Observable`; ordered collection +Semantic identity state is caller-confined `@Observable`; ordered collection topology uses the retained DOM/fetched-results snapshot-plus-delta contracts. -UIKit observes identities through ObservationBridge and consumes topology -updates into diffable/native snapshots. 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. +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: @@ -1194,12 +1268,12 @@ WebInspectorUI keeps a distinct presentation owner: @MainActor @Observable public final class WebInspectorSession { - public let model: WebInspectorModelSession + public let model: WebInspectorModelContext public private(set) var pageUserInterfaceStyle: UIUserInterfaceStyle public init( tabs: [WebInspectorTab] = [.dom, .network], - additionalDomains: Set = [] + additionalDomains: Set = [] ) public func attach(to webView: WKWebView) async throws @@ -1208,13 +1282,13 @@ public final class WebInspectorSession { } public struct WebInspectorTab: Identifiable { - public let requiredDomains: Set + public let requiredDomains: Set public init( id: ID, title: String, systemImage: String, - requiredDomains: Set = [], + requiredDomains: Set = [], makeViewController: @escaping @MainActor (WebInspectorSession) async throws -> UIViewController ) @@ -1225,8 +1299,8 @@ 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 -delegates attachment to `model` and does not copy model lifecycle or domain -state. +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 @@ -1285,18 +1359,39 @@ let container = try await WebInspectorContainer(attachingTo: webView) let context = container.mainContext let requests = context.network.fetchedResults(for: descriptor) -// After: attach success is model readiness, and the query is domain-specific. -let session = WebInspectorModelSession( +// After on MainActor: the convenience initializer returns a ready context. +let context = try await WebInspectorModelContext( + attachingTo: webView, configuration: .init(domains: [.network]) ) -try await session.attach(to: webView) -let requests = try await session.networkRequests( +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) ``` ## Isolation and Deterministic Teardown @@ -1311,13 +1406,21 @@ deinitialization. - `ConnectionCore` (a non-MainActor actor): cancel stored tasks, finish local continuations/streams, and assert or complete synchronous terminal state. - Other non-MainActor actor resources introduced by the final implementation, - such as a test runtime core: synchronously terminate their local waiters. + including Network/Console query indexes and test runtime cores: synchronously + terminate their local waiters and bounded brokers. - `@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. -- `@MainActor` DataKit owners: cancel locally stored tasks and invalidate local - observation state. + +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 local +continuations synchronously. The higher platform floor enables isolated +deinitialization for the actual non-MainActor lifecycle actors instead of +forcing the entire observable graph onto MainActor. It does not perform: @@ -1329,8 +1432,10 @@ It does not perform: 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 dynamic `any Actor` -isolation precisely because it cannot express this ownership boundary. +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 @@ -1345,7 +1450,7 @@ deinitializer is expected to break a cycle. | --- | --- | --- | | Proxy, logical page, typed domain clients/DTOs, structured event scopes | `public` | direct ProxyKit consumer story | | Physical target records, target registry, routing keys, capability registry | `package` or `internal` | one core owns routing; no external producer story | -| DataKit model session, identity models, existing tree/query/resource types, concrete queries | `public` | custom UI story without facade proliferation | +| 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 | @@ -1365,13 +1470,15 @@ The migration removes rather than deprecates the following surfaces: `.currentPage` routing, and `pageBindingID` duplication. - Domain `enable()`, `disable()`, cold `events`, public EventStream wrappers, and DataKit's subscriber barrier/domain enablement registry. -- `WebInspectorContainer`, arbitrary-actor `WebInspectorContext`, EventPump, - dynamic `requireOwner`, and DataKit `@unchecked Sendable` ownership claims. +- `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` wrappers. - Duplicate direct Context/model convenience operations after their - `WebInspectorModelSession` facade operation or scoped-resource equivalent + `WebInspectorModelContext` facade operation or scoped-resource equivalent exists. - `WebInspectorFetchableModel`, `WebInspectorPersistentModel`, generic `WebInspectorFetchDescriptor`, arbitrary key-path sort/section descriptors, @@ -1382,8 +1489,8 @@ The migration removes rather than deprecates the following surfaces: 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 DataKit model session plus genuine - presentation state. + attachment generation; it retains one MainActor-owned DataKit model context + plus genuine presentation state. - Fire-and-forget CSS mutations and hidden partial-delete errors. - Unbounded intermediate Transport -> LiveBackend -> Proxy stream layers where the core broker can route the decoded value directly. @@ -1409,7 +1516,7 @@ the public command surface selected for retention. - **An observable wrapper around Context:** adds mirror state without changing the state owner. - **Public per-domain `WebInspector*Model` or `WebInspector*Store` facades:** - duplicate the session navigation surface and do not improve large-collection + 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. @@ -1418,8 +1525,23 @@ the public command surface selected for retention. - **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 background arbitrary-actor option on the same graph:** restores dynamic - checks and makes Observation callbacks executor-ambiguous. +- **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 @@ -1470,16 +1592,18 @@ Tests are added before each owner is replaced. Required cases: 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 handles without explicit close still reaches isolated deinit; weak - references prove connection and DataKit driver tasks do not form cycles. +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. The isolated-deinit fallback synchronously detaches/restores/finishes exactly once without launching async work. 20. DataKit `attach()` exposes startup failure and does not return before the configured models are ready. -21. Same-view concurrent attach joins, different-view attach supersedes, caller +21. Same-proxy concurrent attach joins, different-proxy attach supersedes, caller cancellation cancels only its wait, detach cancels transition state, a - same-view attach during retarget joins `.synchronizing`, and a failed - session can retry. + 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. @@ -1503,9 +1627,9 @@ Tests are added before each owner is replaced. Required cases: 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 MainActor. + not perform whole-model query evaluation on the context's owner actor. 30. Discarding Network/Console result objects unregisters their weak live-query - sinks and does not retain the model session. + 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 @@ -1515,12 +1639,16 @@ Tests are added before each owner is replaced. Required cases: 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 session, seeds replay, emits picker and +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 a context, identity, or result + cannot cross to another actor, while its Sendable snapshots and deltas can. Validation gates: @@ -1533,8 +1661,9 @@ Validation gates: ## Measurable Completion Criteria -- `WebInspectorContext`, `isolated (any Actor)`, and `requireOwner` counts are - zero in DataKit. +- 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. @@ -1547,9 +1676,11 @@ Validation gates: - 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 contains no `@unchecked Sendable` lifecycle owner. -- Stored connection/model tasks weakly reference owners and deallocation tests - prove no owner-task cycle. +- 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; + deallocation tests prove no owner-task cycle. - Every public DataKit operation has one semantic owner path; the direct/context, controller, and model convenience triplication is gone. - Public `WebInspectorDOMModel`, `WebInspectorNetworkModel`, @@ -1564,10 +1695,10 @@ Validation gates: - 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 MainActor full-model + 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 session-wide generation. + domain epochs rather than one context-wide generation. - One per-web-view inspectability coordinator, not individual attachments, owns original-value restoration. - `WebInspectorSession` owns no controller cache; root-owned custom content may @@ -1591,13 +1722,13 @@ the design gate and implementation: 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 session and model contracts` -7. `refactor(data)!: replace container and context with model session` +6. `test(data): characterize context and model contracts` +7. `refactor(data)!: replace dynamic context with caller confinement` 8. `refactor(data)!: move DOM and CSS state to domain owners` 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 session from the UIKit session` +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 @@ -1608,9 +1739,9 @@ pull request are separate explicit actions. ## Design Gate Implementation begins only after explicit approval of this document. Approval -accepts the breaking removals, the MainActor-owned DataKit session/identity -state, retained snapshot-plus-delta tree and fetched-results contracts, no new -public per-domain Model/Store facades, the split between DataKit's -`WebInspectorModelSession` and UIKit's presentation-only `WebInspectorSession`, -structured ProxyKit event scopes, and the explicit-close plus isolated-deinit -lifecycle contract. +accepts 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. From f7420402f605086bdb9e67e14a10effb756a1d5f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:49:03 +0900 Subject: [PATCH 04/81] docs(architecture): mark binding synchronization --- Docs/WebInspectorKitsArchitecture.md | 48 ++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index d23731ce..6efa4d12 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -561,6 +561,10 @@ package enum ConnectionModelFeedRecord: Sendable { domain: ModelDomain, through: UInt64 ) + case synchronizationComplete( + generation: WebInspectorPage.Generation, + through: UInt64 + ) } package enum ModelProtocolEvent: Sendable { /* typed domain payloads */ } @@ -571,8 +575,12 @@ package enum ModelDomain: Hashable, Sendable { /* configured domains */ } One DataKit feed consumer applies 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. -The feed is package-only because its mixed-domain payload and acknowledgement -boundary are model-adapter mechanics, not a direct ProxyKit consumer concept. +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. Opening the feed registers it and its initial reset record before acquiring any configured capability. Capability acquisition is transactional: if one enable fails, the core releases every capability acquired for that attempt, terminates @@ -589,8 +597,9 @@ 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 is followed by `bootstrapComplete`. Every configured domain must reach either its replay or bootstrap completion -boundary before attachment or retarget synchronization is ready. A later -`DOM.documentUpdated` resets the DOM/CSS epochs and starts the same ordered +boundary before ProxyKit emits `synchronizationComplete`; DataKit applies that +final record before attachment or retarget synchronization becomes ready. A +later `DOM.documentUpdated` resets the DOM/CSS epochs and starts the same ordered snapshot bootstrap again rather than exposing an empty tree as ready state. One-off commands internally acquire their declared prerequisites for the @@ -644,8 +653,9 @@ performs one ordered transition: 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-complete watermark; -6. mark the binding ready and resume command admission. + 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 @@ -906,11 +916,12 @@ public final class WebInspectorModelContext { `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. 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. +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 @@ -1567,8 +1578,10 @@ Tests are added before each owner is replaced. Required cases: 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, and replay-complete markers - preserve transport ordering even when an old-generation consumer is slow. +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 @@ -1577,7 +1590,8 @@ Tests are added before each owner is replaced. Required cases: 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 is applied. + 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. @@ -1599,7 +1613,7 @@ Tests are added before each owner is replaced. Required cases: 19. The isolated-deinit fallback synchronously detaches/restores/finishes exactly once without launching async work. 20. DataKit `attach()` exposes startup failure and does not return before the - configured models are ready. + 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 @@ -1670,8 +1684,8 @@ Validation gates: - 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, and DOM bootstrap boundaries; DataKit has no independent - per-domain event pumps. + enable-replay, DOM bootstrap, and binding synchronization boundaries; DataKit + has no independent per-domain event pumps. - 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. From df3f7535173edc170e848c23cb3994d71a2624d0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:06:04 +0900 Subject: [PATCH 05/81] refactor(proxy)!: centralize connection ownership --- .../LiveWebInspectorProxyBackend.swift | 2 + .../NativeInspectablePage.swift | 163 +++++---- .../NativeInspectorBackend.swift | 15 +- .../Transport/TransportReceiver.swift | 38 ++- .../Transport/TransportSession.swift | 275 +++++++++++++-- .../Transport/TransportTypes.swift | 1 + .../WebInspectorProxy.swift | 314 ++++++------------ .../NativeInspectablePageTests.swift | 15 + .../TransportSessionTests.swift | 94 ++++++ ...torProxyTransportCommandBackendTests.swift | 46 +++ 10 files changed, 642 insertions(+), 321 deletions(-) diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index 52c9a4c4..8a166da0 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -103,6 +103,8 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { switch transportError { case .transportClosed: return WebInspectorProxyError.closed + case let .transportFailure(message): + return WebInspectorProxyError.disconnected(message) case let .replyTimeout(method, _): return WebInspectorProxyError.timeout(domain: domain, method: method) case let .remoteError(method, _, message): diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift index 5746e2db..5184479a 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift @@ -1,56 +1,18 @@ 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 +21,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 +36,90 @@ package enum NativeInspectorConnectionFactory { messageHandler: { message in receiver.receive(message) }, - fatalFailureHandler: fatalFailureHandler + fatalFailureHandler: { message in + fatalFailureHandler(message) + receiver.fail(message) + } ) - let createdTransport = makeTransportSession(backend) - transport = createdTransport - receiver.setTransport(createdTransport) - - try backend.attach() - - return NativeInspectorConnection( - transport: createdTransport, + let createdAttachment = NativeAttachment( receiver: receiver, - reloadPage: { [page] in - try Task.checkCancellation() - try page.reload() - }, - canReloadPage: { [page] in - page.canReload - }, - cleanup: { [page] in - page.restoreInspectabilityIfNeeded() + backend: backend, + page: page + ) + attachment = createdAttachment + let createdCore = ConnectionCore( + backend: backend, + responseTimeout: responseTimeout, + closeAction: { + await createdAttachment.close() } ) + core = createdCore + receiver.setCore(createdCore) + + try backend.attach() + + return createdCore } catch { - receiver.close() - page.restoreInspectabilityIfNeeded() - await transport?.detach() + if let core { + await core.close() + } else if let attachment { + await attachment.close() + } else { + receiver.close() + page.restoreInspectabilityIfNeeded() + } throw error } } } +@MainActor +private final class NativeAttachment { + private let receiver: TransportReceiver + private let backend: NativeInspectorBackend + private let page: NativeInspectablePage + private var isClosed = false + + init( + receiver: TransportReceiver, + backend: NativeInspectorBackend, + page: NativeInspectablePage + ) { + self.receiver = receiver + self.backend = backend + self.page = page + } + + func close() async { + guard !isClosed else { + return + } + isClosed = true + receiver.close() + await backend.detach() + page.restoreInspectabilityIfNeeded() + } + + isolated deinit { + guard !isClosed else { + return + } + receiver.close() + 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 +138,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 +191,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..5c5ce711 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift @@ -4,7 +4,7 @@ import WebInspectorNativeBridge @MainActor package final class NativeInspectorBackend: TransportBackend { - private let webView: WKWebView + 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 +23,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 +48,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/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 5a6a5965..9e6e8acb 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -1,8 +1,16 @@ 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 State: Sendable { - var transport: TransportSession? + var core = WeakCore() var messages: [String] = [] var messageStartIndex = 0 var isDraining = false @@ -14,12 +22,12 @@ package final class TransportReceiver: Sendable { 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 } @@ -41,7 +49,7 @@ package final class TransportReceiver: Sendable { return nil as UInt64? } $0.messages.append(message) - guard $0.transport != nil else { + guard $0.core.value != nil else { return nil } guard !$0.isDraining else { @@ -63,20 +71,32 @@ package final class TransportReceiver: Sendable { state.withLock { $0.isClosed = true $0.generation &+= 1 - $0.transport = nil + $0.core.value = nil $0.messages.removeAll(keepingCapacity: false) $0.messageStartIndex = 0 $0.isDraining = false } } + package func fail(_ message: String) { + let core = state.withLock { state in + guard !state.isClosed else { + return nil as ConnectionCore? + } + return state.core.value + } + Task { [weak core] in + await core?.failFromNativeCallback(message) + } + } + private func drain(generation: UInt64) async { while let next = nextMessage(generation: generation) { - await next.transport.receiveRootMessage(next.message) + await next.core.receiveRootMessage(next.message) } } - private func nextMessage(generation: UInt64) -> (transport: TransportSession, message: String)? { + private func nextMessage(generation: UInt64) -> (core: ConnectionCore, message: String)? { state.withLock { guard !$0.isClosed, $0.generation == generation else { return nil @@ -87,7 +107,7 @@ package final class TransportReceiver: Sendable { $0.isDraining = false return nil } - guard let transport = $0.transport else { + guard let core = $0.core.value else { $0.isDraining = false return nil } @@ -95,7 +115,7 @@ package final class TransportReceiver: Sendable { let message = $0.messages[$0.messageStartIndex] $0.messageStartIndex += 1 compactMessagesIfNeeded(in: &$0) - return (transport, message) + return (core, message) } } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 9d7eddc9..f9d60cf5 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1,8 +1,25 @@ import Foundation -package actor TransportSession { +/// 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 enum TerminalCause: Equatable, Sendable { + case explicitClose + case fatal(String) + } + + private enum State { + case open + case closing(TerminalCause) + case closed(TerminalCause) + } private let backend: any TransportBackend private let responseTimeout: Duration? @@ -11,20 +28,27 @@ package actor TransportSession { 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 inboundMessageQueue: TransportInboundMessageQueue - private var closed: Bool + private var closeAction: CloseAction + private var state: State + private var nextCloseWaiterID: UInt64 + private var closeWaiters: [UInt64: CheckedContinuation] + private var closeWaiterRegistrationWaiters: [CheckedContinuation] + private var cancelledCloseWaiterIDs: Set + private var nextTestTargetOrdinal: UInt64 package init( backend: any TransportBackend, responseTimeout: Duration? = .seconds(5), timeoutSleep: TimeoutSleep? = nil, - responseTimeoutDidFire: ResponseTimeoutDidFire? = nil + responseTimeoutDidFire: ResponseTimeoutDidFire? = nil, + closeAction: CloseAction? = nil ) { self.backend = backend self.responseTimeout = responseTimeout @@ -33,18 +57,43 @@ package actor TransportSession { nextCommandID = 0 eventSequences = TransportEventSequenceTracker() replyStore = TransportReplyStore() - mainPageTargetWaiterStore = TransportSession.MainPageTargetWaiterStore() + mainPageTargetWaiterStore = ConnectionCore.MainPageTargetWaiterStore() targetRegistry = TransportTargetRegistry() provisionalTargetMessageStore = TransportProvisionalTargetMessageStore() styleSheetRouting = TransportStyleSheetRouting() runtimeContextRegistry = RuntimeContextRegistry() eventSubscribers = TransportEventSubscriberRegistry() inboundMessageQueue = TransportInboundMessageQueue() - closed = false + self.closeAction = closeAction ?? { + await backend.detach() + } + state = .open + nextCloseWaiterID = 0 + closeWaiters = [:] + closeWaiterRegistrationWaiters = [] + cancelledCloseWaiterIDs = [] + nextTestTargetOrdinal = 0 + } + + 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. + eventSubscribers.finishAndRemoveAll() + 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.") + } + + private var isOpen: Bool { + if case .open = state { + return true + } + return false } package func events(for domain: ProtocolDomain) -> AsyncStream { - guard !closed else { + guard isOpen else { return finishedStream(of: ProtocolEvent.self) } let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) @@ -58,7 +107,7 @@ package actor TransportSession { } package func orderedEvents() -> AsyncStream { - guard !closed else { + guard isOpen else { return finishedStream(of: ProtocolEvent.self) } let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) @@ -73,8 +122,8 @@ package actor TransportSession { package func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { try Task.checkCancellation() - guard !closed else { - throw TransportSession.Error.transportClosed + guard isOpen else { + throw terminalTransportError } switch command.routing { @@ -102,7 +151,7 @@ package actor TransportSession { @discardableResult package func receiveRootMessage(_ message: String) async -> UInt64 { - guard !closed else { + guard isOpen else { return eventSequences.current.sequence } inboundMessageQueue.append(message) @@ -111,25 +160,73 @@ package actor TransportSession { } package func detach() async { - guard !closed else { + await close() + } + + package func close() async { + await terminate(.explicitClose) + } + + package func failFromNativeCallback(_ message: String) async { + await terminate(.fatal(message)) + } + + package func waitUntilClosed() async throws { + switch state { + case .open, .closing: + break + case let .closed(cause): + return try terminalResult(for: cause).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 } - closed = true - for pending in replyStore.pendingReplies { - await pending.promise.fulfill(.failure(TransportSession.Error.transportClosed)) + await withCheckedContinuation { continuation in + closeWaiterRegistrationWaiters.append(continuation) } - for waiter in mainPageTargetWaiterStore.removeAll() { - await waiter.fulfill(.failure(TransportSession.Error.transportClosed)) + } + + 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? { + switch state { + case .open: + nil + case let .closing(cause), let .closed(cause): + cause } - 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 + guard isOpen else { + throw terminalTransportError } if let currentMainPageTargetID = targetRegistry.currentMainPageTargetID { return TransportSession.MainPageTarget( @@ -188,6 +285,36 @@ package actor TransportSession { targetRegistry.targetID(forFrameID: frameID) } + package func currentMainPageRecord() -> ProtocolTarget.Record? { + guard isOpen, + let targetID = targetRegistry.currentMainPageTargetID else { + return nil + } + return targetRegistry.target(for: targetID) + } + + package func installTargetForTesting( + kind: ProtocolTarget.Kind, + frameID: ProtocolFrame.ID?, + isProvisional: Bool + ) -> ProtocolTarget.Record { + precondition(isOpen, "Cannot install a target after ConnectionCore starts closing.") + let ordinal = nextTestTargetOrdinal + nextTestTargetOrdinal &+= 1 + let id = ProtocolTarget.ID("test-target-\(ordinal)") + let record = ProtocolTarget.Record( + id: id, + kind: kind, + frameID: frameID, + parentFrameID: nil, + capabilities: .resolved(for: kind, domainNames: nil), + isProvisional: isProvisional, + isPaused: false + ) + _ = targetRegistry.recordTargetCreated(record) + return record + } + private func sendRoot(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { let commandID = allocateCommandID() let promise = ReplyPromise() @@ -890,8 +1017,110 @@ package actor TransportSession { } replyStore.markTargetReplyAsBufferedIfNeeded(commandID: commandID, targetID: targetID) } + + private var terminalTransportError: TransportSession.Error { + switch state { + case .open, .closing(.explicitClose), .closed(.explicitClose): + .transportClosed + case let .closing(.fatal(message)), let .closed(.fatal(message)): + .transportFailure(message) + } + } + + private func terminate(_ cause: TerminalCause) async { + switch state { + case .open: + state = .closing(cause) + case .closing: + try? await waitUntilClosed() + return + case .closed: + return + } + + let transportError = terminalTransportError + for pending in replyStore.pendingReplies { + await pending.promise.fulfill(.failure(transportError)) + } + for waiter in mainPageTargetWaiterStore.removeAll() { + await waiter.fulfill(.failure(transportError)) + } + replyStore.removeAll() + provisionalTargetMessageStore.removeAll() + inboundMessageQueue = TransportInboundMessageQueue() + eventSubscribers.finishAndRemoveAll() + + let closeAction = self.closeAction + await closeAction() + + // 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 else { + preconditionFailure("ConnectionCore terminal state changed outside terminate(_:).") + } + state = .closed(cause) + resumeCloseWaiters(with: terminalResult(for: cause)) + } + + private func terminalResult(for cause: TerminalCause) -> Result { + switch cause { + case .explicitClose: + .success(()) + case let .fatal(message): + .failure(WebInspectorProxyError.disconnected(message)) + } + } + + private func registerCloseWaiter( + id: UInt64, + continuation: CheckedContinuation + ) { + if case let .closed(cause) = state { + continuation.resume(with: terminalResult(for: cause)) + 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 diff --git a/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift b/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift index 5eee6d0a..e225dac9 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift @@ -220,5 +220,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/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index f4233b13..7d2657a6 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -23,8 +23,9 @@ private struct ProtocolCommandTarget: Sendable { /// 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 +/// `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 /// ``WebInspectorTarget`` values. /// /// Example: @@ -61,21 +62,7 @@ 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 /// Attaches a Web Inspector protocol connection to a web view. /// @@ -86,9 +73,9 @@ public actor WebInspectorProxy { 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 +87,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) @@ -128,15 +105,11 @@ public actor WebInspectorProxy { ) { self.configuration = configuration self.backend = backend - transport = nil - self.closeConnection = closeConnection - pageTarget = nil - nextTargetOrdinal = 0 - nextCloseWaiterID = 0 - closeWaiters = [:] - closeWaiterRegistrationWaiters = [] - cancelledCloseWaiterIDs = [] - closeState = .open + core = ConnectionCore( + backend: UnavailableTransportBackend(), + responseTimeout: configuration.responseTimeout, + closeAction: closeConnection + ) } package init( @@ -145,18 +118,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) @@ -168,17 +135,26 @@ public actor WebInspectorProxy { /// The currently known page target, if bootstrap has completed. public var currentPage: WebInspectorTarget? { - pageTarget + get async { + guard let record = await core.currentMainPageRecord() else { + return nil + } + return try? currentPageTarget(from: record) + } } package var currentPageBindingID: String? { - pageTarget?.pageBindingID + get async { + await core.currentMainPageRecord()?.id.rawValue + } } /// 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() != nil + } } /// Waits for and returns the current page target. @@ -187,21 +163,15 @@ public actor WebInspectorProxy { /// 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 + 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,19 +181,14 @@ 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 { @@ -232,9 +197,7 @@ public actor WebInspectorProxy { /// 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 pageTarget = try await waitForCurrentPage() let _: Void = try await dispatchCommand( targetID: pageTarget.id, route: pageTarget.route, @@ -249,20 +212,7 @@ public actor WebInspectorProxy { /// 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() } /// Suspends until ``close()`` has finished. @@ -270,53 +220,32 @@ 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 + ) async -> WebInspectorTarget { + let record = await core.installTargetForTesting( + kind: kind.protocolKind, + frameID: frameID.map { ProtocolFrame.ID($0.rawValue) }, + isProvisional: isProvisional + ) let target = WebInspectorTarget( - id: WebInspectorTarget.ID("test-target-\(ordinal)"), + id: WebInspectorTarget.ID(record.id.rawValue), kind: kind, frameID: frameID, isProvisional: isProvisional, proxy: self, - route: RoutingTargetID("test-route-\(ordinal)") + route: RoutingTargetID(record.id.rawValue) ) - if kind == .page && isProvisional == false { - pageTarget = target - } return target } 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) - } + await core.waitForCloseWaiterForTesting() } package func dispatchCommand( @@ -326,8 +255,12 @@ public actor WebInspectorProxy { method: String, payload: Payload ) 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) @@ -466,7 +399,6 @@ public actor WebInspectorProxy { guard case let .targetLifecycle(value) = event else { preconditionFailure("Backend emitted a mismatched event for lifecycle.") } - await self.applyTargetLifecycleEventToProxyState(value) continuation.yield(value) } } @@ -814,90 +746,32 @@ public actor WebInspectorProxy { } 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() - } - - private func cancelCloseWaiter(_ id: UInt64) { - guard let continuation = closeWaiters.removeValue(forKey: id) else { - if closeState != .closed { - cancelledCloseWaiterIDs.insert(id) - } - return - } - continuation.resume(throwing: CancellationError()) + return try currentPageTarget(from: record) } - 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 +790,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 +799,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 +836,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/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/TransportSessionTests.swift b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift index aa65f6e2..f9aed4ee 100644 --- a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift @@ -1159,6 +1159,88 @@ func orderedStreamReceivesTargetEventsAcrossDomainsInTransportOrder() async thro #expect(recordedEvents.map(\.sequence) == [1, 2, 3, 4]) } +@Test +func nativeFatalCallbackOwnsTerminalCauseAndFailsPendingWork() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + let receiver = TransportReceiver() + receiver.setCore(core) + + let sendTask = Task { + try await core.send( + ProtocolCommand(domain: .target, method: "Target.setPauseOnStart", routing: .root) + ) + } + _ = try await waitForRootMessage(backend) + + let closeWaitTask = Task { + try await core.waitUntilClosed() + } + await core.waitForCloseWaiterForTesting() + + receiver.fail("native callback failed") + + 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 connectionCoreCloseIsIdempotentAndFinishesStreamsOnce() async throws { + let backend = CountingTransportBackend() + let core = ConnectionCore(backend: backend) + let stream = await core.events(for: .network) + let nextEventTask = Task { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + + async let firstClose: Void = core.close() + async let secondClose: Void = core.close() + _ = await (firstClose, secondClose) + + #expect(await backend.detachCount == 1) + #expect(await nextEventTask.value == nil) + try await core.waitUntilClosed() +} + +@Test +func receiverDoesNotKeepExplicitlyClosedConnectionCoreAlive() async { + let backend = FakeTransportBackend() + let receiver = TransportReceiver() + weak var weakCore: ConnectionCore? + + do { + let core = ConnectionCore(backend: backend) + weakCore = core + receiver.setCore(core) + await core.close() + } + + #expect(weakCore == nil) +} + +@Test +func receiverDoesNotKeepDroppedConnectionCoreAlive() { + let backend = FakeTransportBackend() + 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 { private let storage = ProtocolEventRecorderStorage() private let task: Task @@ -1186,6 +1268,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/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index abc9fe0c..2eee0373 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -586,6 +586,27 @@ func transportBackedProxyWaitUntilClosedSuspendsUntilClose() async throws { #expect(await backend.isDetached()) } +@Test +func nativeFatalCallbackPropagatesThroughProxyTerminalAPI() async throws { + let backend = FakeTransportBackend() + 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() + + receiver.fail("native frontend disconnected") + + await #expect(throws: WebInspectorProxyError.disconnected("native frontend disconnected")) { + try await proxy.waitUntilClosed() + } + 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() @@ -598,6 +619,31 @@ func proxyWaitUntilClosedReturnsImmediatelyAfterClose() async throws { try await throwingValue(of: waitTask, timeout: .milliseconds(100)) } +@Test +func proxyHandleDeallocatesAfterExplicitClose() async { + weak var weakProxy: WebInspectorProxy? + + do { + let proxy = WebInspectorProxy() + weakProxy = proxy + await proxy.close() + } + + #expect(weakProxy == nil) +} + +@Test +func droppingOpenProxyHandleReachesConnectionCoreDeinit() { + weak var weakProxy: WebInspectorProxy? + + do { + let proxy = WebInspectorProxy() + weakProxy = proxy + } + + #expect(weakProxy == nil) +} + @Test func proxyWaitUntilClosedWaitsForInFlightCloseConnection() async throws { let closeGate = CloseConnectionGate() From 61c519ce37af33c6d0ac3d86b8ee3d56d0167efe Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:26:20 +0900 Subject: [PATCH 06/81] test(proxy): cover native attachment lifecycle graph --- .../NativeInspectablePage.swift | 10 +- .../NativeInspectorBackend.swift | 9 +- .../NativeAttachmentLifecycleTests.swift | 410 ++++++++++++++++++ 3 files changed, 423 insertions(+), 6 deletions(-) create mode 100644 Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift index 5184479a..c44dcf59 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift @@ -75,15 +75,15 @@ package enum NativeConnectionCoreFactory { } @MainActor -private final class NativeAttachment { +package final class NativeAttachment { private let receiver: TransportReceiver - private let backend: NativeInspectorBackend + private let backend: any NativeAttachmentBackend private let page: NativeInspectablePage private var isClosed = false - init( + package init( receiver: TransportReceiver, - backend: NativeInspectorBackend, + backend: any NativeAttachmentBackend, page: NativeInspectablePage ) { self.receiver = receiver @@ -91,7 +91,7 @@ private final class NativeAttachment { self.page = page } - func close() async { + package func close() async { guard !isClosed else { return } diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift index 5c5ce711..8547c0ef 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift @@ -2,8 +2,15 @@ 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 { +package final class NativeInspectorBackend: NativeAttachmentBackend { private weak var webView: WKWebView? private let resolvedSymbols: NativeInspectorResolvedSymbols private nonisolated let messageHandler: @Sendable (String) -> Void diff --git a/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift new file mode 100644 index 00000000..3a1e078e --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift @@ -0,0 +1,410 @@ +#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 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 + ) { + 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, + 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 + } +} + +@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 From 61f547f550b8349ae80a37da8c3baf4d818c593c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:24:50 +0900 Subject: [PATCH 07/81] refactor(datakit)!: make fetched results publication atomic Make fetched-results updates self-contained and newest-one bounded, remove the forwarding controller, and migrate Network UI to consume results directly. Order NetworkRequestIndex mutations by contiguous sequence and retain per-result checkpoints so overlapping property updates cannot be lost. BREAKING CHANGE: Remove WebInspectorFetchedResultsController and parameterize fetched-results transactions by item ID. --- .../ContractTestSupport.swift | 18 +- .../StoryADataKitImportOnlyContract.swift | 17 +- Docs/WebInspectorKitsArchitecture.md | 33 +- Sources/WebInspectorDataKit/Fetching.swift | 312 ++++++----- .../NetworkRequestIndex.swift | 154 ++++-- .../WebInspectorContext.swift | 94 +--- .../WebInspectorDataKit.md | 3 +- .../WebInspectorDomainControllers.swift | 20 - ...bInspectorFetchedResultsPublication.swift} | 293 +++++++++-- .../List/NetworkListViewController.swift | 133 +++-- .../WebInspectorDataKitTests.swift | 495 ++++++++++++++++-- .../NetworkDetailViewControllerTests.swift | 28 +- 12 files changed, 1151 insertions(+), 449 deletions(-) rename Sources/WebInspectorDataKit/{WebInspectorFetchedResultsController.swift => WebInspectorFetchedResultsPublication.swift} (59%) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index 85f4ba5e..18f7071c 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -197,19 +197,14 @@ actor ContractDataKitActor { 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(requestResults.snapshot.itemIDs.isEmpty) + #expect(consoleResults.snapshot.itemIDs.isEmpty) + _ = requestResults.updates() + _ = consoleResults.updates() #expect(context.state == .attached) let root = try #require(context.rootNode) @@ -316,8 +311,7 @@ actor ContractDataKitActor { url: "https://example.com/data.json", headers: ["Accept": "application/json"] ) - let requestController: WebInspectorFetchedResultsController = - context.fetchedResultsController() + let requestResults: WebInspectorFetchedResults = context.fetchedResults() await ContractTestSupport.emitFinishedRequest(request, target: target, backend: runtime.backend) let requests: WebInspectorFetchedResults = context.fetchedResults() @@ -342,7 +336,7 @@ actor ContractDataKitActor { #expect(requestModel.metrics?.encodedDataLength == 4) #expect(requestModel.metrics?.decodedBodyLength == 7) #expect(context.registeredRequest(for: requestModel.id) === requestModel) - #expect(requestController.snapshot.itemIDs == [requestModel.id]) + #expect(requestResults.snapshot.itemIDs == [requestModel.id]) await runtime.backend.enqueue( Network.Body(data: "{\"ok\":true}", base64Encoded: false), diff --git a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift index 7c402e5b..a15469c5 100644 --- a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift +++ b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift @@ -14,11 +14,6 @@ private actor DataKitImportOnlyActor { context.fetchedResults(sectionBy: \.method) let messagesByLevel: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) - let requestController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - let messageController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - _ = context.state _ = context.rootNode?.children _ = context.selectedNode?.attributes @@ -55,19 +50,21 @@ private actor DataKitImportOnlyActor { _ = requests.items.first?.metrics _ = requestsByMethod.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() _ = requestTransaction.hasChanges _ = messages.items.first?.text _ = messages.items.first?.parameters.first?.description _ = messagesByLevel.sections.first?.id - _ = messageController.snapshot - _ = messageController.transactions + _ = messages.snapshot + _ = messages.revision + _ = messages.updates() _ = try await context.evaluate("1 + 1").object.description let request = NetworkRequestSnapshot(url: "https://example.com", method: "GET") diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 6efa4d12..71d85829 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1207,10 +1207,10 @@ 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. Coalescing may -replace an unconsumed `.initial`, but the surviving transaction is itself a -complete current snapshot. Thus slow consumers converge without unbounded -backlog or silent stale state. +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 @@ -1223,12 +1223,13 @@ keeping the model context alive. The zero-state `WebInspectorFetchedResultsController` forwarding wrapper is deleted; the snapshot, transaction, and result types are retained. -Concretely, when `Continuation.yield(new)` reports `.dropped(old)`, the broker -forms `merged` with `old.reconfigureItemIDs ∪ new.reconfigureItemIDs` and the -newest revision/snapshot, then yields that replacement once; the just-enqueued -unmerged value is the element displaced. The resulting revision gap tells the -consumer to use the full newest snapshot, while the union preserves all cell -reconfiguration work. +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 @@ -1236,6 +1237,18 @@ changes, maintains registered query membership/order, and returns identity snapshots and deltas; query replacement may perform a full scan on that actor, but a live insert must not dereference or filter every identity model on its owner actor. +The index is also the sole owner of each identity's last mutation sequence. +Replace and upsert operations enter one sequence-keyed queue; the index drains +only the next contiguous operation, and each caller waits until its operation +has been applied. Cross-actor job scheduling therefore cannot create a hole in +the scalar high-water checkpoint. +Every result keeps its own successfully applied index checkpoint, and a delta +contains all still-visible identities changed after that checkpoint. Therefore, +if an overlapping query is discarded because a newer index sequence arrived, +the next delta recovers its property-only reconfiguration work instead of only +reporting the mutation that happened to trigger the newer query. Query +replacement and reset establish the checkpoint explicitly; stale or duplicate +deltas cannot advance it. 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 diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 5aeb2189..771b85f8 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -180,7 +180,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 @@ -301,26 +302,57 @@ private enum WebInspectorKnownKeyPaths { /// Observable collection of models produced by a fetch descriptor. @Observable public final class WebInspectorFetchedResults { + private struct State { + var fetchDescriptor: WebInspectorFetchDescriptor + var sectionBy: WebInspectorSectionDescriptor? + var items: [Model] + var sections: [WebInspectorFetchSection] + var snapshot: WebInspectorFetchedResultsSnapshot + var revision: UInt64 + var topologyRevision: UInt64 + } + + private var state: State + @ObservationIgnored private let updateBroker = + WebInspectorFetchedResultsUpdateBroker() + @ObservationIgnored weak var modelContext: WebInspectorContext? + @ObservationIgnored private var networkQueryPlan: NetworkRequestQueryPlan? + @ObservationIgnored private var networkQueryState: NetworkRequestQueryState? + @ObservationIgnored private var networkIndexSequence: UInt64 + /// The descriptor currently used by the results. - public private(set) var fetchDescriptor: WebInspectorFetchDescriptor + public var fetchDescriptor: WebInspectorFetchDescriptor { + state.fetchDescriptor + } /// The section descriptor currently used by the results. - public private(set) var sectionBy: WebInspectorSectionDescriptor? + public var sectionBy: WebInspectorSectionDescriptor? { + state.sectionBy + } /// The fetched models in display order. - public private(set) var items: [Model] + public var items: [Model] { + state.items + } /// The fetched models grouped into display sections. - public private(set) var sections: [WebInspectorFetchSection] - package private(set) var topologyRevision: Int + public var sections: [WebInspectorFetchSection] { + state.sections + } - @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? + /// The complete current section and item identity snapshot. + public var snapshot: WebInspectorFetchedResultsSnapshot { + state.snapshot + } + + /// Monotonically increasing publication revision. + public var revision: UInt64 { + state.revision + } + + package var topologyRevision: UInt64 { + state.topologyRevision + } init( fetchDescriptor: WebInspectorFetchDescriptor, @@ -328,65 +360,76 @@ public final class WebInspectorFetchedResults items: [Model] = [], modelContext: WebInspectorContext? = nil ) { - self.fetchDescriptor = fetchDescriptor - self.sectionBy = sectionBy - self.items = items - sections = Self.sections(for: items, sectionBy: sectionBy) + let sections = Self.sections(for: items, sectionBy: sectionBy) + state = State( + fetchDescriptor: fetchDescriptor, + sectionBy: sectionBy, + items: items, + sections: sections, + snapshot: WebInspectorFetchedResultsSnapshot(sections: sections), + revision: 0, + topologyRevision: 0 + ) self.modelContext = modelContext - topologyRevision = 0 networkQueryPlan = nil networkQueryState = nil - networkResultSnapshot = nil + networkIndexSequence = 0 } deinit { - transactionRelay.finish() + updateBroker.finish() } - func makeTransactionStream() -> AsyncStream> { - transactionRelay.makeStream() + /// 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> { + updateBroker.makeStream(initial: .initial( + revision: state.revision, + snapshot: state.snapshot + )) } 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) + let sections = Self.sections(for: items, sectionBy: state.sectionBy) + publish(items: items, sections: sections, updatedItemIDs: updatedItemIDs) } 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: []) + precondition( + state.items.contains { $0.id == item.id } == false, + "WebInspectorFetchedResults cannot insert a duplicate item ID." + ) + let items = state.items + [item] + let sections = Self.sections(for: items, sectionBy: state.sectionBy) + publish(items: items, sections: sections) } func refreshAfterItemMutation(_ item: Model) { - guard items.contains(where: { $0.id == item.id }) else { + guard state.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]) + let sections = Self.sections(for: state.items, sectionBy: state.sectionBy) + publish(items: state.items, sections: sections, updatedItemIDs: [item.id]) } func resetItems(_ items: [Model]) { - let oldSnapshot = currentSnapshot - self.items = items - sections = Self.sections(for: items, sectionBy: sectionBy) - bumpTopologyRevision() - yieldResetTransaction(oldSnapshot: oldSnapshot) + let sections = Self.sections(for: items, sectionBy: state.sectionBy) + publish(items: items, sections: sections, isReset: true) } func applyFetchDescriptor(_ descriptor: WebInspectorFetchDescriptor, items: [Model]) { - fetchDescriptor = descriptor - resetItems(items) + let sections = Self.sections(for: items, sectionBy: state.sectionBy) + publish( + items: items, + sections: sections, + fetchDescriptor: descriptor, + isReset: true + ) } /// Replaces the fetch descriptor and updates the result contents. @@ -400,52 +443,67 @@ public final class WebInspectorFetchedResults modelContext.updateFetchDescriptor(descriptor, for: self, isolation: isolation) } - private var currentSnapshot: WebInspectorFetchedResultsSnapshot { - WebInspectorFetchedResultsSnapshot(sections: sections) - } - - private func bumpTopologyRevisionIfNeeded(oldSnapshot: WebInspectorFetchedResultsSnapshot) { - guard oldSnapshot != currentSnapshot else { - return - } - bumpTopologyRevision() - } - - private func bumpTopologyRevision() { - topologyRevision &+= 1 - } - - private func yieldTransaction( - oldSnapshot: WebInspectorFetchedResultsSnapshot, - updatedItemIDs: Set + private func publish( + items: [Model], + sections: [WebInspectorFetchSection], + snapshot: WebInspectorFetchedResultsSnapshot? = nil, + fetchDescriptor: WebInspectorFetchDescriptor? = nil, + isReset: Bool = false, + transaction suppliedTransaction: WebInspectorFetchedResultsTransaction? = nil, + updatedItemIDs: Set = [] ) { - guard transactionRelay.hasContinuations else { + let oldState = state + let newSnapshot = snapshot ?? WebInspectorFetchedResultsSnapshot(sections: sections) + let reconfigureItemIDs = updatedItemIDs.intersection(newSnapshot.itemIDs) + guard isReset + || oldState.snapshot != newSnapshot + || reconfigureItemIDs.isEmpty == false else { return } - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: currentSnapshot, - updatedItemIDs: updatedItemIDs - ) - guard transaction.hasChanges else { - return - } - transactionRelay.yield(transaction) - } - private func yieldResetTransaction( - oldSnapshot: WebInspectorFetchedResultsSnapshot - ) { - guard transactionRelay.hasContinuations else { - return + let transaction: WebInspectorFetchedResultsTransaction + if let suppliedTransaction { + precondition( + suppliedTransaction.oldSnapshot == oldState.snapshot + && suppliedTransaction.newSnapshot == newSnapshot, + "A fetched-results transaction must describe the state publication it accompanies." + ) + transaction = suppliedTransaction + } else if isReset { + transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldState.snapshot, + newSnapshot: newSnapshot, + isReset: true, + itemChanges: [] + ) + } else { + transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldState.snapshot, + newSnapshot: newSnapshot, + updatedItemIDs: reconfigureItemIDs + ) } - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: currentSnapshot, - isReset: true, - itemChanges: [] + + let revision = oldState.revision &+ 1 + let topologyRevision = if isReset || oldState.snapshot != newSnapshot { + oldState.topologyRevision &+ 1 + } else { + oldState.topologyRevision + } + state = State( + fetchDescriptor: fetchDescriptor ?? oldState.fetchDescriptor, + sectionBy: oldState.sectionBy, + items: items, + sections: sections, + snapshot: newSnapshot, + revision: revision, + topologyRevision: topologyRevision ) - transactionRelay.yield(transaction) + updateBroker.yield(.transaction( + revision: revision, + transaction: transaction, + reconfigureItemIDs: reconfigureItemIDs + )) } private static func sections( @@ -510,19 +568,18 @@ public final class WebInspectorFetchedResults extension WebInspectorFetchedResults where Model == NetworkRequest { var networkSnapshotForDelta: WebInspectorFetchedResultsSnapshot { - if let networkResultSnapshot { - return networkResultSnapshot - } - let snapshot = WebInspectorFetchedResultsSnapshot(sections: sections) - networkResultSnapshot = snapshot - return snapshot + state.snapshot + } + + var networkIndexSequenceForDelta: UInt64 { + networkIndexSequence } func currentNetworkQueryPlan(context: WebInspectorContext) -> NetworkRequestQueryPlan { if let networkQueryPlan { return networkQueryPlan } - let plan = NetworkRequestQueryPlan(descriptor: fetchDescriptor, context: context) + let plan = NetworkRequestQueryPlan(descriptor: state.fetchDescriptor, context: context) networkQueryPlan = plan return plan } @@ -530,6 +587,7 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { func setNetworkItems( _ requests: [NetworkRequest], plan: NetworkRequestQueryPlan, + indexSequence: UInt64, lookup: (NetworkRequest.ID) -> NetworkRequest? ) { networkQueryPlan = plan @@ -541,34 +599,42 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { networkQueryState = nil setItems(requests) } - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + networkIndexSequence = indexSequence } func applyNetworkFetchDescriptor( _ descriptor: WebInspectorFetchDescriptor, plan: NetworkRequestQueryPlan, requests: [NetworkRequest], + indexSequence: UInt64, lookup: (NetworkRequest.ID) -> NetworkRequest? ) { - fetchDescriptor = descriptor networkQueryPlan = plan + let visibleRequests: [NetworkRequest] if plan.requiresQuery { let state = NetworkRequestQueryState(plan: plan, requests: requests) networkQueryState = state - resetItems(state.visibleRequests(lookup: lookup)) + visibleRequests = state.visibleRequests(lookup: lookup) } else { networkQueryState = nil - resetItems(requests) - } - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + visibleRequests = requests + } + let sections = Self.sections(for: visibleRequests, sectionBy: state.sectionBy) + publish( + items: visibleRequests, + sections: sections, + fetchDescriptor: descriptor, + isReset: true + ) + networkIndexSequence = indexSequence } - func resetNetworkItems() { + func resetNetworkItems(indexSequence: UInt64) { if let state = networkQueryState { networkQueryState = NetworkRequestQueryState(plan: state.plan, requests: []) } resetItems([]) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot() + networkIndexSequence = indexSequence } func insertNetworkRequest( @@ -577,13 +643,11 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { ) { 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) } func refreshNetworkRequestAfterMutation( @@ -592,37 +656,45 @@ extension WebInspectorFetchedResults where Model == 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) } func applyNetworkDelta( _ delta: NetworkResultSetDelta, lookup: (NetworkRequest.ID) -> NetworkRequest? ) { - let oldSnapshot = networkSnapshotForDelta - items = delta.snapshot.itemIDs.compactMap(lookup) - sections = delta.snapshot.sections.map { section in + guard delta.sequence > networkIndexSequence else { + return + } + let items = delta.snapshot.itemIDs.map { id in + guard let request = lookup(id) else { + preconditionFailure("A NetworkRequestIndex snapshot referenced an unregistered request.") + } + return request + } + let sections = delta.snapshot.sections.map { section in WebInspectorFetchSection( id: section.id, title: section.title, - items: section.itemIDs.compactMap(lookup) + items: section.itemIDs.map { id in + guard let request = lookup(id) else { + preconditionFailure("A NetworkRequestIndex section referenced an unregistered request.") + } + return request + } ) } - networkResultSnapshot = delta.snapshot - if oldSnapshot != delta.snapshot { - bumpTopologyRevision() - } - guard transactionRelay.hasContinuations, - let transaction = delta.transaction, - transaction.hasChanges else { - return - } - transactionRelay.yield(transaction) + publish( + items: items, + sections: sections, + snapshot: delta.snapshot, + transaction: delta.transaction, + updatedItemIDs: delta.reconfigureItemIDs + ) + networkIndexSequence = delta.sequence } } diff --git a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index 8c553203..b6623829 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -1,49 +1,95 @@ import Foundation package struct NetworkResultSetDelta: Sendable { + package var sequence: UInt64 package var snapshot: WebInspectorFetchedResultsSnapshot - package var transaction: WebInspectorFetchedResultsTransaction? + package var transaction: WebInspectorFetchedResultsTransaction + package var reconfigureItemIDs: Set } package actor NetworkRequestIndex { + private enum Mutation { + case replace([NetworkRequestRecordInput]) + case upsert(NetworkRequestRecordInput) + } + + private struct PendingMutation { + var mutation: Mutation + var continuation: CheckedContinuation + } + private var recordsByID: [NetworkRequest.ID: NetworkRequestRecord] = [:] private var orderedIDs: [NetworkRequest.ID] = [] + private var lastUpdatedSequenceByID: [NetworkRequest.ID: UInt64] = [:] private var lastAppliedSequence: UInt64 = 0 + private var pendingMutationsBySequence: [UInt64: PendingMutation] = [:] 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) + package func replace(with inputs: [NetworkRequestRecordInput], sequence: UInt64) async { + await enqueue(.replace(inputs), sequence: sequence) + } + + package func upsert(_ input: NetworkRequestRecordInput, sequence: UInt64) async { + await enqueue(.upsert(input), sequence: sequence) + } + + private func enqueue(_ mutation: Mutation, sequence: UInt64) async { + precondition( + sequence > lastAppliedSequence, + "NetworkRequestIndex received an already-applied mutation sequence." + ) + precondition( + pendingMutationsBySequence[sequence] == nil, + "NetworkRequestIndex received a duplicate mutation sequence." + ) + 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 { + return + } + apply(pending.mutation, sequence: sequence) + lastAppliedSequence = sequence + pending.continuation.resume() } - upsertRecord(input) + precondition( + pendingMutationsBySequence.isEmpty, + "NetworkRequestIndex mutation sequence overflowed." + ) } - private func apply(sequence: UInt64) -> Bool { - guard sequence > lastAppliedSequence else { - return false + private func apply(_ mutation: Mutation, sequence: UInt64) { + switch mutation { + case let .replace(inputs): + recordsByID = [:] + recordsByID.reserveCapacity(inputs.count) + orderedIDs = [] + orderedIDs.reserveCapacity(inputs.count) + lastUpdatedSequenceByID = [:] + lastUpdatedSequenceByID.reserveCapacity(inputs.count) + for input in inputs { + upsertRecord(input, sequence: sequence) + } + case let .upsert(input): + upsertRecord(input, sequence: sequence) } - 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) } @@ -53,23 +99,34 @@ package actor NetworkRequestIndex { plan: NetworkRequestQueryPlan, sectionBy: WebInspectorSectionDescriptor?, oldSnapshot: WebInspectorFetchedResultsSnapshot, - changedID: NetworkRequest.ID? - ) -> NetworkResultSetDelta? { - guard plan.requiresModelPredicate == false else { - return nil - } + changedSince sequence: UInt64 + ) -> NetworkResultSetDelta { + precondition(plan.requiresModelPredicate == false) + let indexSequence = lastAppliedSequence let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) - guard oldSnapshot != newSnapshot else { - return nil - } + let oldItemIDs = Set(oldSnapshot.itemIDs) + let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in + oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence + }) let transaction = NetworkResultSetTransactionBuilder.transaction( oldSnapshot: oldSnapshot, newSnapshot: newSnapshot, - changedID: changedID + updatedItemIDs: updatedItemIDs + ) + return NetworkResultSetDelta( + sequence: indexSequence, + snapshot: newSnapshot, + transaction: transaction, + reconfigureItemIDs: updatedItemIDs ) - return NetworkResultSetDelta(snapshot: newSnapshot, transaction: transaction) } +#if DEBUG + package func isMutationPendingForTesting(sequence: UInt64) -> Bool { + pendingMutationsBySequence[sequence] != nil + } +#endif + private func snapshot( plan: NetworkRequestQueryPlan, sectionBy: WebInspectorSectionDescriptor? @@ -170,18 +227,21 @@ private enum NetworkResultSetTransactionBuilder { static func transaction( oldSnapshot: Snapshot, newSnapshot: Snapshot, - changedID: ItemID? - ) -> WebInspectorFetchedResultsTransaction? { + updatedItemIDs: Set + ) -> WebInspectorFetchedResultsTransaction { let sectionChanges = sectionChanges(from: oldSnapshot, to: newSnapshot) - let itemChanges = itemChanges(from: oldSnapshot, to: newSnapshot, changedID: changedID) - let transaction = WebInspectorFetchedResultsTransaction( + let itemChanges = itemChanges( + from: oldSnapshot, + to: newSnapshot, + updatedItemIDs: updatedItemIDs + ) + return WebInspectorFetchedResultsTransaction( oldSnapshot: oldSnapshot, newSnapshot: newSnapshot, isReset: false, sectionChanges: sectionChanges, itemChanges: itemChanges ) - return transaction.hasChanges ? transaction : nil } private static func sectionChanges( @@ -229,7 +289,7 @@ private enum NetworkResultSetTransactionBuilder { private static func itemChanges( from oldSnapshot: Snapshot, to newSnapshot: Snapshot, - changedID: ItemID? + updatedItemIDs: Set ) -> [WebInspectorFetchedResultsItemChange] { let oldPositions = indexItems(oldSnapshot) let newPositions = indexItems(newSnapshot) @@ -266,11 +326,22 @@ private enum NetworkResultSetTransactionBuilder { to: newSnapshot, oldPositions: oldPositions, newPositions: newPositions, - changedID: changedID, + updatedItemIDs: updatedItemIDs, excludedItemIDs: Set(sectionMembershipChanges.map(itemID)) ) - return deletes + inserts + sectionMembershipChanges + moves + 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( @@ -314,7 +385,7 @@ private enum NetworkResultSetTransactionBuilder { to newSnapshot: Snapshot, oldPositions: [ItemID: ItemPosition], newPositions: [ItemID: ItemPosition], - changedID: ItemID?, + updatedItemIDs: Set, excludedItemIDs: Set ) -> [WebInspectorFetchedResultsItemChange] { let oldCommonOrder = oldSnapshot.itemIDs.filter { newPositions[$0] != nil } @@ -323,7 +394,8 @@ private enum NetworkResultSetTransactionBuilder { return [] } - if let changedID, + if updatedItemIDs.count == 1, + let changedID = updatedItemIDs.first, excludedItemIDs.contains(changedID) == false, let oldPosition = oldPositions[changedID], let newPosition = newPositions[changedID], diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index b23fccc4..a78d9537 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -794,6 +794,7 @@ public final class WebInspectorContext { networkResults.setNetworkItems( currentNetworkRequests(), plan: plan, + indexSequence: networkRequestIndexSequence, lookup: { id in self.requestsByID[id] } ) networkFetchedResults.append(WeakWebInspectorFetchedResults(networkResults)) @@ -874,79 +875,6 @@ public final class WebInspectorContext { ) } - /// 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, @@ -968,6 +896,7 @@ public final class WebInspectorContext { networkDescriptor, plan: plan, requests: currentNetworkRequests(), + indexSequence: networkRequestIndexSequence, lookup: { id in self.requestsByID[id] } ) case .consoleMessages: @@ -3960,7 +3889,13 @@ extension WebInspectorContext { } private func nextNetworkRequestIndexSequence() -> UInt64 { - networkRequestIndexSequence &+= 1 + // The index drains a contiguous operation log, so every allocated + // sequence must be submitted exactly once to replace or upsert. + precondition( + networkRequestIndexSequence < UInt64.max, + "NetworkRequestIndex mutation sequence overflowed." + ) + networkRequestIndexSequence += 1 return networkRequestIndexSequence } @@ -4037,15 +3972,14 @@ extension WebInspectorContext { } let oldSnapshot = results.networkSnapshotForDelta let resultTopologyRevision = results.topologyRevision + let resultIndexSequence = results.networkIndexSequenceForDelta let indexSequence = networkRequestIndexSequence - guard let delta = await networkRequestIndex.delta( + let delta = await networkRequestIndex.delta( plan: plan, sectionBy: results.sectionBy, oldSnapshot: oldSnapshot, - changedID: request.id - ) else { - continue - } + changedSince: resultIndexSequence + ) guard networkRequestIndexSequence == indexSequence, results.topologyRevision == resultTopologyRevision, results.networkSnapshotForDelta == oldSnapshot else { @@ -4058,7 +3992,7 @@ extension WebInspectorContext { private func resetNetworkFetchedResults() { networkFetchedResults.removeAll { $0.value == nil } for registration in networkFetchedResults { - registration.value?.resetNetworkItems() + registration.value?.resetNetworkItems(indexSequence: networkRequestIndexSequence) } } } diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md index 70b81160..da686892 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md +++ b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md @@ -84,7 +84,8 @@ access with no model layer. - ``NetworkRequest`` - ``ConsoleMessage`` - ``WebInspectorFetchRequest`` -- ``WebInspectorFetchedResultsController`` +- ``WebInspectorFetchedResults`` +- ``WebInspectorFetchedResultsUpdate`` ### Runtime and CSS Models diff --git a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift b/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift index 7421fc79..5c998fbc 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift +++ b/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift @@ -290,17 +290,6 @@ public final class NetworkModelController { 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) @@ -323,15 +312,6 @@ public final class ConsoleModelController { 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. diff --git a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift similarity index 59% rename from Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift rename to Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift index cd5621d6..ac53c8a9 100644 --- a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift +++ b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization /// Section/item position inside fetched results. public struct WebInspectorFetchedResultsIndexPath: Hashable, Sendable { @@ -73,9 +74,9 @@ public struct WebInspectorFetchedResultsSnapshot: H } extension WebInspectorFetchedResultsSnapshot { - init( + init( sections: [WebInspectorFetchSection] - ) where Model.ID == ItemID { + ) where Model.ID == ItemID, Model.ID: Hashable & Sendable { self.init(sections: sections.map { section in Section( id: section.id, @@ -120,10 +121,7 @@ public enum WebInspectorFetchedResultsItemChange: H } /// 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 - +public struct WebInspectorFetchedResultsTransaction: Hashable, Sendable { /// Snapshot before the transaction. public let oldSnapshot: WebInspectorFetchedResultsSnapshot @@ -312,6 +310,250 @@ public struct WebInspectorFetchedResultsTransaction: 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 WebInspectorFetchedResultsItemChange { fileprivate var newIndexPathForOrdering: WebInspectorFetchedResultsIndexPath { switch self { @@ -337,42 +579,3 @@ extension WebInspectorFetchedResultsIndexPath: Comparable { 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/WebInspectorUINetwork/List/NetworkListViewController.swift b/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift index 8410ebc6..209cfbda 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) } diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index db46e569..ba67d65c 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -3092,12 +3092,363 @@ func setChildNodesReplacementPublishesSelectionClearingForRemovedDescendant() as @MainActor @Test -func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async throws { +func fetchedResultsUpdatesStartWithExactCurrentState() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let requestID = seedFetchedResultsRequest( + "initial-publication", + url: "https://example.com/initial", + timestamp: 1, + in: context + ) + let results: WebInspectorFetchedResults = context.fetchedResults() + var iterator = results.updates().makeAsyncIterator() + + guard case let .initial(revision, snapshot)? = await iterator.next() else { + Issue.record("Expected the first fetched-results publication to be initial state.") + return + } + + #expect(revision == results.revision) + #expect(snapshot == results.snapshot) + #expect(snapshot.itemIDs == [requestID]) +} + +@MainActor +@Test +func fetchedResultsUpdatesCannotMissMutationAfterRegistration() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let results: WebInspectorFetchedResults = context.fetchedResults() + var iterator = results.updates().makeAsyncIterator() + + let requestID = seedFetchedResultsRequest( + "registered-before-mutation", + url: "https://example.com/registered", + timestamp: 1, + in: context + ) + + guard case let .initial(revision, snapshot)? = await iterator.next() else { + Issue.record("Expected the pending initial value to coalesce to current state.") + return + } + + #expect(revision == results.revision) + #expect(snapshot == results.snapshot) + #expect(snapshot.itemIDs == [requestID]) +} + +@MainActor +@Test +func fetchedResultsPropertyUpdatesDoNotInvalidateTopologyQueries() { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let requestID = seedFetchedResultsRequest( + "property-generation", + url: "https://example.com/original", + timestamp: 1, + in: context + ) + let results: WebInspectorFetchedResults = context.fetchedResults() + let initialRevision = results.revision + let initialTopologyRevision = results.topologyRevision + + seedFetchedResultsRequest( + "property-generation", + url: "https://example.com/updated", + timestamp: 2, + in: context + ) + + #expect(results.revision == initialRevision &+ 1) + #expect(results.topologyRevision == initialTopologyRevision) + #expect(results.snapshot.itemIDs == [requestID]) +} + +@MainActor +@Test +func networkRequestIndexDrainsDifferentIDMutationsInSequenceOrder() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let firstID = seedFetchedResultsRequest( + "ordered-first", + url: "https://example.com/first", + timestamp: 1, + in: context + ) + let secondID = seedFetchedResultsRequest( + "ordered-second", + url: "https://example.com/second", + timestamp: 2, + in: context + ) + let first = try #require(context.registeredRequest(for: firstID)) + let second = try #require(context.registeredRequest(for: secondID)) + let index = NetworkRequestIndex() + + let secondMutation = Task { + await index.upsert( + NetworkRequestRecordInput(request: second, orderIndex: 1), + sequence: 2 + ) + } + try await waitUntil { + await index.isMutationPendingForTesting(sequence: 2) + } + await index.upsert( + NetworkRequestRecordInput(request: first, orderIndex: 0), + sequence: 1 + ) + await secondMutation.value + + let delta = await index.delta( + plan: NetworkRequestQueryPlan( + descriptor: WebInspectorFetchDescriptor(), + context: context + ), + sectionBy: nil, + oldSnapshot: WebInspectorFetchedResultsSnapshot(), + changedSince: 0 + ) + #expect(delta.sequence == 2) + #expect(delta.snapshot.itemIDs == [firstID, secondID]) +} + +@MainActor +@Test +func networkRequestIndexBuffersUpsertUntilEarlierReplaceArrives() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let firstID = seedFetchedResultsRequest( + "replace-first", + url: "https://example.com/first", + timestamp: 1, + in: context + ) + let secondID = seedFetchedResultsRequest( + "replace-second", + url: "https://example.com/second", + timestamp: 2, + in: context + ) + let first = try #require(context.registeredRequest(for: firstID)) + let second = try #require(context.registeredRequest(for: secondID)) + let index = NetworkRequestIndex() + + let upsert = Task { + await index.upsert( + NetworkRequestRecordInput(request: second, orderIndex: 1), + sequence: 2 + ) + } + try await waitUntil { + await index.isMutationPendingForTesting(sequence: 2) + } + await index.replace( + with: [NetworkRequestRecordInput(request: first, orderIndex: 0)], + sequence: 1 + ) + await upsert.value + + let delta = await index.delta( + plan: NetworkRequestQueryPlan( + descriptor: WebInspectorFetchDescriptor(), + context: context + ), + sectionBy: nil, + oldSnapshot: WebInspectorFetchedResultsSnapshot(), + changedSince: 0 + ) + #expect(delta.sequence == 2) + #expect(delta.snapshot.itemIDs == [firstID, secondID]) +} + +@MainActor +@Test +func networkRequestIndexCheckpointRecoversSkippedOverlappingPropertyUpdates() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let firstID = seedFetchedResultsRequest( + "overlap-first", + url: "https://example.com/first", + timestamp: 1, + in: context + ) + let secondID = seedFetchedResultsRequest( + "overlap-second", + url: "https://example.com/second", + timestamp: 2, + in: context + ) + let first = try #require(context.registeredRequest(for: firstID)) + let second = try #require(context.registeredRequest(for: secondID)) + let plan = NetworkRequestQueryPlan( + descriptor: WebInspectorFetchDescriptor(), + context: context + ) + let index = NetworkRequestIndex() + await index.replace( + with: [ + NetworkRequestRecordInput(request: first, orderIndex: 0), + NetworkRequestRecordInput(request: second, orderIndex: 1), + ], + sequence: 1 + ) + let initial = await index.delta( + plan: plan, + sectionBy: nil, + oldSnapshot: WebInspectorFetchedResultsSnapshot(), + changedSince: 0 + ) + #expect(initial.sequence == 1) + #expect(initial.snapshot.itemIDs == [firstID, secondID]) + + seedFetchedResultsRequest( + "overlap-first", + url: "https://example.com/first-updated", + timestamp: 3, + in: context + ) + await index.upsert( + NetworkRequestRecordInput(request: first, orderIndex: 0), + sequence: 2 + ) + let skippedFirstDelta = await index.delta( + plan: plan, + sectionBy: nil, + oldSnapshot: initial.snapshot, + changedSince: initial.sequence + ) + #expect(skippedFirstDelta.reconfigureItemIDs == [firstID]) + + seedFetchedResultsRequest( + "overlap-second", + url: "https://example.com/second-updated", + timestamp: 4, + in: context + ) + await index.upsert( + NetworkRequestRecordInput(request: second, orderIndex: 1), + sequence: 3 + ) + + // Model the context discarding `skippedFirstDelta` after a newer index + // sequence arrived. The next delta starts at the result's unchanged + // checkpoint and must recover both property notifications. + let recoveredDelta = await index.delta( + plan: plan, + sectionBy: nil, + oldSnapshot: initial.snapshot, + changedSince: initial.sequence + ) + #expect(recoveredDelta.sequence == 3) + #expect(recoveredDelta.reconfigureItemIDs == Set([firstID, secondID])) + #expect(recoveredDelta.snapshot == initial.snapshot) +} + +@MainActor +@Test +func stalledFetchedResultsSubscriberKeepsOnlyNewestUpdateAndUnionsReconfigurations() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let firstID = seedFetchedResultsRequest( + "stalled-first", + url: "https://example.com/first", + timestamp: 1, + in: context + ) + let secondID = seedFetchedResultsRequest( + "stalled-second", + url: "https://example.com/second", + timestamp: 2, + in: context + ) + let firstIdentity = try #require(context.registeredRequest(for: firstID)) + let secondIdentity = try #require(context.registeredRequest(for: secondID)) + + var results: WebInspectorFetchedResults? = context.fetchedResults() + weak let weakResults = results + var iterator = try #require(results).updates().makeAsyncIterator() + guard case .initial? = await iterator.next() else { + Issue.record("Expected fetched-results initial state before stalling the subscriber.") + return + } + + seedFetchedResultsRequest( + "stalled-first", + url: "https://example.com/first-updated", + timestamp: 3, + in: context + ) + seedFetchedResultsRequest( + "stalled-second", + url: "https://example.com/second-updated", + timestamp: 4, + in: context + ) + + let expectedRevision = try #require(results).revision + let expectedSnapshot = try #require(results).snapshot + results = nil + #expect(weakResults == nil) + #expect(context.registeredRequest(for: firstID) === firstIdentity) + #expect(context.registeredRequest(for: secondID) === secondIdentity) + + guard case let .transaction(revision, transaction, reconfigureItemIDs)? = await iterator.next() else { + Issue.record("Expected the newest coalesced fetched-results transaction.") + return + } + #expect(revision == expectedRevision) + #expect(transaction.newSnapshot == expectedSnapshot) + #expect(reconfigureItemIDs == Set([firstID, secondID])) + + let remainingUpdate = await iterator.next() + #expect(remainingUpdate == nil) +} + +@MainActor +@Test +func stalledFetchedResultsSubscriberDoesNotReconfigureRemovedItems() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let requestID = seedFetchedResultsRequest( + "stalled-removed", + url: "https://example.com/original", + timestamp: 1, + in: context + ) + var results: WebInspectorFetchedResults? = context.fetchedResults() + var iterator = try #require(results).updates().makeAsyncIterator() + guard case .initial? = await iterator.next() else { + Issue.record("Expected fetched-results initial state before stalling the subscriber.") + return + } + + seedFetchedResultsRequest( + "stalled-removed", + url: "https://example.com/updated", + timestamp: 2, + in: context + ) + context.clearNetworkRequests() + + let expectedRevision = try #require(results).revision + results = nil + + guard case let .transaction(revision, transaction, reconfigureItemIDs)? = await iterator.next() else { + Issue.record("Expected the coalesced removal transaction.") + return + } + #expect(revision == expectedRevision) + #expect(transaction.isReset) + #expect(transaction.newSnapshot.itemIDs.isEmpty) + #expect(reconfigureItemIDs.isEmpty) + #expect(context.registeredRequest(for: requestID) == nil) + + let remainingUpdate = await iterator.next() + #expect(remainingUpdate == nil) +} + +@MainActor +@Test +func fetchedResultsPublishNetworkTopologyAndPropertyUpdates() 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3120,7 +3471,8 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th #expect(inserted.itemChanges == [.insert(itemID: modelID, indexPath: firstIndexPath)]) #expect(inserted.oldSnapshot.itemIDs == []) #expect(inserted.newSnapshot.itemIDs == [modelID]) - #expect(controller.snapshot.itemIDs == [modelID]) + #expect(recorder.reconfigureItemIDSets.last == []) + #expect(results.snapshot.itemIDs == [modelID]) let request = try #require(results.items.first) await runtime.backend.emit( @@ -3134,8 +3486,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { request.status == 200 } + try await recorder.waitForTransactionCount(2) #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) await runtime.backend.emit( .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), @@ -3143,8 +3497,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { request.decodedDataLength == 7 && request.encodedDataLength == 3 } + try await recorder.waitForTransactionCount(3) #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) await runtime.backend.emit( .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), @@ -3152,8 +3508,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { request.state == .finished } + try await recorder.waitForTransactionCount(4) #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) await runtime.backend.emit( .requestServedFromMemoryCache( @@ -3166,8 +3524,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { request.finishedOrFailedTimestamp == 5 } + try await recorder.waitForTransactionCount(5) #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) let failedRequestID = Network.Request.ID("controller-failed-request") let failedModelID = NetworkRequest.ID(failedRequestID) @@ -3183,8 +3543,9 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th target: target ) - try await recorder.waitForTransactionCount(2) + try await recorder.waitForTransactionCount(6) #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: failedModelID, indexPath: failedIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == []) let failedRequest = try #require(results.items.last) await runtime.backend.emit( @@ -3198,8 +3559,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th } return false } + try await recorder.waitForTransactionCount(7) #expect(results.items.last === failedRequest) - #expect(recorder.transactions.count == 2) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: failedModelID, indexPath: failedIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [failedModelID]) let socketRequestID = Network.Request.ID("controller-socket-request") let socketModelID = NetworkRequest.ID(socketRequestID) @@ -3209,8 +3572,9 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th target: target ) - try await recorder.waitForTransactionCount(3) + try await recorder.waitForTransactionCount(8) #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == []) let socketRequest = try #require(results.items.last) await runtime.backend.emit( @@ -3228,8 +3592,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.webSocket?.handshakeRequest?.headers["Upgrade"] == "websocket" } + try await recorder.waitForTransactionCount(9) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) await runtime.backend.emit( .webSocket(.handshakeResponse( @@ -3241,8 +3607,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.status == 101 } + try await recorder.waitForTransactionCount(10) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) await runtime.backend.emit( .webSocket(.frameSent( @@ -3254,8 +3622,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.webSocket?.frames.count == 1 } + try await recorder.waitForTransactionCount(11) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) await runtime.backend.emit( .webSocket(.frameReceived( @@ -3267,8 +3637,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.webSocket?.frames.count == 2 } + try await recorder.waitForTransactionCount(12) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) await runtime.backend.emit( .webSocket(.error(id: socketRequestID, message: "decode failed", timestamp: 12)), @@ -3276,8 +3648,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.webSocket?.frames.count == 3 } + try await recorder.waitForTransactionCount(13) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) await runtime.backend.emit( .webSocket(.closed(id: socketRequestID, timestamp: 13)), @@ -3285,8 +3659,10 @@ func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async th ) try await waitUntil { socketRequest.state == .finished && socketRequest.webSocket?.readyState == .closed } + try await recorder.waitForTransactionCount(14) #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) } @MainActor @@ -3295,8 +3671,7 @@ 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3314,8 +3689,8 @@ func sectionedNetworkResultsPublishTopologyWhenSectionKeyChanges() async throws ) try await recorder.waitForTransactionCount(1) - #expect(controller.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) - #expect(controller.snapshot.itemIDs == [modelID]) + #expect(results.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) + #expect(results.snapshot.itemIDs == [modelID]) await runtime.backend.emit( .responseReceived( @@ -3350,8 +3725,7 @@ func sectionedNetworkResultsPublishTopologyWhenSectionKeyChanges() async throws 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3394,11 +3768,11 @@ func sectionedNetworkResultsPublishItemMoveWhenSectionKeyChangesBetweenExistingS )) try await recorder.waitForTransactionCount(4) - #expect(controller.snapshot.sections.map(\.id) == [ + #expect(results.snapshot.sections.map(\.id) == [ WebInspectorFetchSectionID(rawValue: "image"), WebInspectorFetchSectionID(rawValue: "xhrFetch"), ]) - #expect(controller.snapshot.sections.map(\.itemIDs) == [ + #expect(results.snapshot.sections.map(\.itemIDs) == [ [NetworkRequest.ID(imageID), movingModelID], [NetworkRequest.ID(remainingXHRID)], ]) @@ -3587,8 +3961,7 @@ func networkFetchDescriptorOrdersEqualTimestampsByNewestInsertionFirst() async t sortBy: [SortDescriptor(\.requestSentTimestamp, order: .reverse)] ) let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3632,8 +4005,7 @@ func networkFetchDescriptorPublishesPredicateEnterAndLeave() async throws { } ) let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3814,8 +4186,7 @@ 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -3846,7 +4217,7 @@ func clearNetworkRequestsPublishesResetAndIgnoresClearedEvents() async throws { target: target ) try await recorder.waitForTransactionCount(2) - #expect(controller.snapshot.itemIDs == [firstModelID, secondModelID]) + #expect(results.snapshot.itemIDs == [firstModelID, secondModelID]) context.clearNetworkRequests() @@ -3857,7 +4228,7 @@ func clearNetworkRequestsPublishesResetAndIgnoresClearedEvents() async throws { #expect(reset.newSnapshot.itemIDs == []) #expect(reset.sectionChanges == []) #expect(reset.itemChanges == []) - #expect(controller.snapshot.itemIDs == []) + #expect(results.snapshot.itemIDs == []) #expect(results.items.isEmpty) #expect(context.registeredRequest(for: firstModelID) == nil) #expect(context.registeredRequest(for: secondModelID) == nil) @@ -4008,12 +4379,11 @@ func networkRequestExposesDataKitQueryableProperties() async throws { @MainActor @Test -func fetchedResultsControllerPublishesConsoleInsertUpdateAndDeleteTransactions() async throws { +func fetchedResultsPublishConsoleInsertUpdateAndDeleteTransactions() 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -4027,7 +4397,7 @@ func fetchedResultsControllerPublishesConsoleInsertUpdateAndDeleteTransactions() ) try await recorder.waitForTransactionCount(1) - let firstID = try #require(controller.snapshot.itemIDs.first) + let firstID = try #require(results.snapshot.itemIDs.first) #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0))]) await runtime.backend.emit( @@ -4048,7 +4418,7 @@ func fetchedResultsControllerPublishesConsoleInsertUpdateAndDeleteTransactions() ) try await recorder.waitForTransactionCount(3) - let secondID = try #require(controller.snapshot.itemIDs.last) + let secondID = try #require(results.snapshot.itemIDs.last) #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1))]) await runtime.backend.emit( @@ -4061,7 +4431,7 @@ func fetchedResultsControllerPublishesConsoleInsertUpdateAndDeleteTransactions() .delete(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1)), .delete(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), ]) - #expect(controller.snapshot.itemIDs == []) + #expect(results.snapshot.itemIDs == []) } @MainActor @@ -4070,8 +4440,7 @@ 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) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) defer { recorder.cancel() } try await recorder.waitUntilStarted() @@ -4089,7 +4458,7 @@ func fetchedResultsCanBeSectionedByStringKeyPath() async throws { try await recorder.waitForTransactionCount(1) #expect(results.sections.map(\.title) == ["GET"]) - #expect(controller.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "GET")]) + #expect(results.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "GET")]) #expect(recorder.transactions.last?.sectionChanges == [ .insert(sectionID: WebInspectorFetchSectionID(rawValue: "GET"), index: 0) ]) @@ -4176,7 +4545,7 @@ func fetchedResultsTransactionDiffsMovesByItemID() { let oldSnapshot = WebInspectorFetchedResultsSnapshot(itemIDs: [first, second]) let newSnapshot = WebInspectorFetchedResultsSnapshot(itemIDs: [second, first]) - let transaction = WebInspectorFetchedResultsTransaction( + let transaction = WebInspectorFetchedResultsTransaction( oldSnapshot: oldSnapshot, newSnapshot: newSnapshot ) @@ -7335,23 +7704,49 @@ private final class DOMTreeRevealRequestRecorder { } @MainActor -private final class FetchedResultsTransactionRecorder { - private(set) var transactions: [WebInspectorFetchedResultsTransaction] = [] +@discardableResult +private func seedFetchedResultsRequest( + _ requestID: String, + url: String, + timestamp: Double, + in context: WebInspectorContext +) -> NetworkRequest.ID { + context.seedNetworkRequest( + requestID: requestID, + url: url, + resourceTypeRawValue: "Fetch", + responseMIMEType: "text/plain", + responseStatus: 200, + responseStatusText: "OK", + timestamp: timestamp + ) +} + +@MainActor +private final class FetchedResultsTransactionRecorder { + private(set) var updates: [WebInspectorFetchedResultsUpdate] = [] + private(set) var transactions: [WebInspectorFetchedResultsTransaction] = [] + private(set) var reconfigureItemIDSets: [Set] = [] private var task: Task? - private var hasStarted = false + private var hasConsumedInitialUpdate = false - init(stream: AsyncStream>) { + init(stream: AsyncStream>) { task = Task { @MainActor [weak self] in - self?.hasStarted = true - for await transaction in stream { + for await update in stream { + self?.updates.append(update) + guard case let .transaction(_, transaction, reconfigureItemIDs) = update else { + self?.hasConsumedInitialUpdate = true + continue + } self?.transactions.append(transaction) + self?.reconfigureItemIDSets.append(reconfigureItemIDs) } } } func waitUntilStarted() async throws { - try await waitUntil { self.hasStarted } + try await waitUntil { self.hasConsumedInitialUpdate } } func waitForTransactionCount(_ count: Int) async throws { diff --git a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift index fd5abd17..941fc6f8 100644 --- a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift +++ b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift @@ -1756,7 +1756,7 @@ struct NetworkDetailViewControllerTests { } @Test - func visibleListAppliesLiveInsertThroughFetchedResultsTransactions() async throws { + func visibleListAppliesLiveInsertThroughFetchedResultsUpdates() async throws { let context = makeContext() let firstRequest = try #require(await applyRequest( to: context, @@ -1773,7 +1773,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 +1783,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 +1791,7 @@ struct NetworkDetailViewControllerTests { } @Test - func visibleListAppliesDescriptorResetThroughFetchedResultsTransactions() async throws { + func visibleListAppliesDescriptorResetThroughFetchedResultsUpdates() async throws { let context = makeContext() _ = try #require(await applyRequest( to: context, @@ -1811,13 +1811,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) @@ -1845,13 +1845,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) @@ -1985,7 +1985,7 @@ struct NetworkDetailViewControllerTests { } @Test - func listControllerDeallocatesWhileFetchedResultsTransactionTaskIsActive() async throws { + func listControllerDeallocatesWhileFetchedResultsUpdateTaskIsActive() async throws { let model = NetworkPanelModel(context: makeContext()) let deinitProbe = UITestDeinitProbe() weak var weakViewController: NetworkListViewController? @@ -2271,10 +2271,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 } From c5f7800ef36e1d810e285fba56165b1b29f3a6bb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:51:09 +0900 Subject: [PATCH 08/81] test(ui): share observation waits across platforms --- Tests/WebInspectorUITests/UITestObservationWaits.swift | 2 -- 1 file changed, 2 deletions(-) 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 From 68cdf4a8a98b960fbd3b755178b3c574d74d3150 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:20:49 +0900 Subject: [PATCH 09/81] feat(proxy)!: add atomic structured event scopes BREAKING CHANGE: domain event scopes now use connection-owned capability leases, bounded buffering, and fail-fast lifecycle semantics. --- .../WebInspectorContext.swift | 6 +- Sources/WebInspectorProxyKit/CSS.swift | 22 + Sources/WebInspectorProxyKit/Console.swift | 22 + Sources/WebInspectorProxyKit/DOM.swift | 22 + .../LiveProxyEventDecoder.swift | 6 +- .../LiveWebInspectorProxyBackend.swift | 122 +- Sources/WebInspectorProxyKit/Network.swift | 25 + Sources/WebInspectorProxyKit/Runtime.swift | 22 + .../StructuredEventScopes.swift | 444 ++++ .../ConnectionEventScopeRegistry.swift | 187 ++ ...ansportProvisionalTargetMessageStore.swift | 9 - .../Transport/TransportReplyStore.swift | 20 - .../Transport/TransportSession.swift | 1187 ++++++++- .../Transport/TransportTargetRegistry.swift | 52 +- .../WebInspectorProxy.swift | 13 + .../WebInspectorProxyBackend.swift | 34 + .../WebInspectorProxyError.swift | 18 + .../WebInspectorTarget.swift | 24 + .../NetworkBodyViewController.swift | 9 +- .../TransportReplyStoreTests.swift | 16 +- .../TransportSessionTests.swift | 138 +- ...torProxyTransportCommandBackendTests.swift | 2132 +++++++++++++++-- 22 files changed, 4129 insertions(+), 401 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/StructuredEventScopes.swift create mode 100644 Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index a78d9537..03d331c5 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -1907,11 +1907,11 @@ public final class WebInspectorContext { switch error { case let proxyError as WebInspectorProxyError: switch proxyError { - case .disconnected, .unsupported, .attachFailed: + case .disconnected, .unsupported, .attachFailed, .protocolViolation, .transportFailure: fail(proxyError) - case .closed: + case .closed, .pageUnavailable: WebInspectorDataKitLog.debug("\(operation) raced connection close") - case .commandFailed, .timeout: + case .staleIdentifier, .commandFailed, .commandRejected, .eventBufferOverflow, .timeout: WebInspectorDataKitLog.debug("\(operation) failed: \(String(describing: proxyError))") } default: diff --git a/Sources/WebInspectorProxyKit/CSS.swift b/Sources/WebInspectorProxyKit/CSS.swift index 73370ddc..095ed03f 100644 --- a/Sources/WebInspectorProxyKit/CSS.swift +++ b/Sources/WebInspectorProxyKit/CSS.swift @@ -28,6 +28,28 @@ public enum CSS { ) } + /// 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 context.withEvents( + domain: .css, + buffering: buffering, + isolation: isolation, + extract: { event in + guard case let .css(value) = event else { + return nil + } + return value + }, + operation + ) + } + /// Returns cascade information for the supplied DOM node. public func matchedStyles(for node: DOM.Node.ID) async throws -> MatchedStyles { try await context.dispatch( diff --git a/Sources/WebInspectorProxyKit/Console.swift b/Sources/WebInspectorProxyKit/Console.swift index f08980f5..f0ef2c36 100644 --- a/Sources/WebInspectorProxyKit/Console.swift +++ b/Sources/WebInspectorProxyKit/Console.swift @@ -28,6 +28,28 @@ public enum Console { ) } + /// 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 context.withEvents( + domain: .console, + buffering: buffering, + isolation: isolation, + extract: { event in + guard case let .console(value) = event else { + return nil + } + return value.event + }, + operation + ) + } + /// Clears console messages in the inspected target. public func clearMessages() async throws { try await context.dispatchVoid( diff --git a/Sources/WebInspectorProxyKit/DOM.swift b/Sources/WebInspectorProxyKit/DOM.swift index 8e4b5ca9..953f3f7f 100644 --- a/Sources/WebInspectorProxyKit/DOM.swift +++ b/Sources/WebInspectorProxyKit/DOM.swift @@ -25,6 +25,28 @@ public enum DOM { 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 context.withEvents( + domain: .dom, + buffering: buffering, + isolation: isolation, + extract: { event in + guard case let .dom(value) = event else { + return nil + } + return value + }, + operation + ) + } + /// Returns the root document node for the target. public func getDocument() async throws -> Node { try await context.dispatch( diff --git a/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift index bf8fe70a..0ec6a851 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": @@ -308,7 +308,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 8a166da0..fd2958fc 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -50,6 +50,26 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } + package func acquireEventScope( + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + domain: WebInspectorProxyEventDomain, + 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 func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws { + try await transport.releaseEventScope(id) + } + package nonisolated func events( route: RoutingTargetID, targetID: WebInspectorTarget.ID, @@ -141,15 +161,22 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } private nonisolated func shouldDeliver(_ event: ProtocolEvent, to route: RoutingTargetID) async -> Bool { + let snapshot = await transport.snapshot() + return Self.shouldDeliver(event, to: route, in: snapshot) + } + + 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 } - 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 @@ -176,27 +203,27 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { guard event.method != "DOM.documentUpdated" else { return false } - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) case .inspector: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + return Self.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) + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) case .css: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) case .console: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) case .runtime: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) default: return false } } } - private nonisolated func isCurrentPageFrameTarget( + private nonisolated static func isCurrentPageFrameTarget( _ record: ProtocolTarget.Record, in snapshot: TransportSession.Snapshot, currentMainPageTargetID: ProtocolTarget.ID @@ -239,34 +266,43 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { route: RoutingTargetID ) async -> WebInspectorProxyEvent { let snapshot = await transport.snapshot() - let scopedProxyEvent = scopedAgentOwnedEvent(proxyEvent, from: event, route: route, snapshot: snapshot) + return Self.projectedEvent(proxyEvent, from: event, route: route, in: snapshot) + } + + 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 } if event.domain == .inspector, targetID == snapshot.currentMainPageTargetID { - return mainPageInspectorEvent(scopedProxyEvent) + return Self.mainPageInspectorEvent(scopedProxyEvent) } guard let currentMainPageTargetID = snapshot.currentMainPageTargetID, targetID != currentMainPageTargetID, let record = snapshot.targetsByID[targetID], - isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) else { + Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) else { return scopedProxyEvent } switch scopedProxyEvent { case let .dom(domEvent): - return .dom(scopedDOMEvent(domEvent, targetRawValue: targetID.rawValue)) + return .dom(Self.scopedDOMEvent(domEvent, targetRawValue: targetID.rawValue)) case let .css(cssEvent): - return .css(scopedCSSEvent(cssEvent, targetRawValue: targetID.rawValue)) + return .css(Self.scopedCSSEvent(cssEvent, targetRawValue: targetID.rawValue)) case let .network(networkEvent): - return .network(scopedNetworkEvent(networkEvent, targetRawValue: targetID.rawValue)) + return .network(Self.scopedNetworkEvent(networkEvent, targetRawValue: targetID.rawValue)) default: return scopedProxyEvent } } - private nonisolated func scopedAgentOwnedEvent( + private nonisolated static func scopedAgentOwnedEvent( _ proxyEvent: WebInspectorProxyEvent, from event: ProtocolEvent, route: RoutingTargetID, @@ -286,7 +322,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func runtimeAgentScopeRawValue( + private nonisolated static func runtimeAgentScopeRawValue( for event: ProtocolEvent, route: RoutingTargetID, snapshot: TransportSession.Snapshot @@ -306,7 +342,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return agentTargetID.rawValue } - private nonisolated func mainPageInspectorEvent( + private nonisolated static func mainPageInspectorEvent( _ proxyEvent: WebInspectorProxyEvent ) -> WebInspectorProxyEvent { guard case let .inspector(event) = proxyEvent, @@ -316,7 +352,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return .inspector(.inspect(object, hints: hints, origin: nil)) } - private nonisolated func scopedDOMEvent( + private nonisolated static func scopedDOMEvent( _ event: DOM.Event, targetRawValue: String ) -> DOM.Event { @@ -380,7 +416,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedDOMNode( + private nonisolated static func scopedDOMNode( _ node: DOM.Node, targetRawValue: String ) -> DOM.Node { @@ -408,7 +444,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - private nonisolated func scopedDOMNodeID( + private nonisolated static func scopedDOMNodeID( _ id: DOM.Node.ID, targetRawValue: String ) -> DOM.Node.ID { @@ -418,7 +454,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return DOM.Node.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) } - private nonisolated func scopedCSSEvent( + private nonisolated static func scopedCSSEvent( _ event: CSS.Event, targetRawValue: String ) -> CSS.Event { @@ -448,7 +484,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedStyleSheetID( + private nonisolated static func scopedStyleSheetID( _ id: CSS.StyleSheet.ID, targetRawValue: String ) -> CSS.StyleSheet.ID { @@ -458,7 +494,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return CSS.StyleSheet.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) } - private nonisolated func scopedRuntimeEvent( + private nonisolated static func scopedRuntimeEvent( _ event: Runtime.Event, targetScopeRawValue: String? ) -> Runtime.Event { @@ -482,7 +518,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedExecutionContextID( + private nonisolated static func scopedExecutionContextID( _ id: Runtime.ExecutionContext.ID, targetRawValue: String ) -> Runtime.ExecutionContext.ID { @@ -492,7 +528,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return Runtime.ExecutionContext.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) } - private nonisolated func scopedConsoleEvent( + private nonisolated static func scopedConsoleEvent( _ event: Console.Event, targetScopeRawValue: String? ) -> Console.Event { @@ -511,7 +547,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedConsoleMessage( + private nonisolated static func scopedConsoleMessage( _ message: Console.Message, targetRawValue: String ) -> Console.Message { @@ -533,7 +569,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - private nonisolated func scopedRemoteObject( + private nonisolated static func scopedRemoteObject( _ object: Runtime.RemoteObject, targetRawValue: String ) -> Runtime.RemoteObject { @@ -549,7 +585,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - private nonisolated func scopedRemoteObjectID( + private nonisolated static func scopedRemoteObjectID( _ id: Runtime.RemoteObject.ID, targetRawValue: String ) -> Runtime.RemoteObject.ID { @@ -559,7 +595,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return Runtime.RemoteObject.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) } - private nonisolated func scopedNetworkEvent( + private nonisolated static func scopedNetworkEvent( _ event: Network.Event, targetRawValue: String ) -> Network.Event { @@ -614,7 +650,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedWebSocketEvent( + private nonisolated static func scopedWebSocketEvent( _ event: Network.WebSocketEvent, targetRawValue: String ) -> Network.WebSocketEvent { @@ -646,7 +682,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func scopedNetworkRequest( + private nonisolated static func scopedNetworkRequest( _ request: Network.Request, targetRawValue: String ) -> Network.Request { @@ -662,7 +698,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - private nonisolated func scopedNetworkRequestID( + private nonisolated static func scopedNetworkRequestID( _ id: Network.Request.ID, targetRawValue: String ) -> Network.Request.ID { @@ -677,22 +713,36 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { route: RoutingTargetID, targetID: WebInspectorTarget.ID ) async -> WebInspectorLifecycleTarget? { + let snapshot = await transport.snapshot() + return Self.lifecycleTarget( + for: event, + route: route, + targetID: targetID, + in: snapshot + ) + } + + 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 } - let snapshot = await transport.snapshot() guard let record = snapshot.targetsByID[protocolTargetID] else { return nil } return WebInspectorLifecycleTarget( - semanticID: semanticTargetID(for: route, targetID: targetID), + semanticID: Self.semanticTargetID(for: route, targetID: targetID), record: record ) } - private nonisolated func semanticTargetID( + private nonisolated static func semanticTargetID( for route: RoutingTargetID, targetID: WebInspectorTarget.ID ) -> WebInspectorTarget.ID { diff --git a/Sources/WebInspectorProxyKit/Network.swift b/Sources/WebInspectorProxyKit/Network.swift index 939eec85..3eaa5bb8 100644 --- a/Sources/WebInspectorProxyKit/Network.swift +++ b/Sources/WebInspectorProxyKit/Network.swift @@ -28,6 +28,31 @@ public enum Network { ) } + /// 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 context.withEvents( + domain: .network, + buffering: buffering, + isolation: isolation, + extract: { event in + guard case let .network(value) = event else { + return nil + } + return value + }, + operation + ) + } + /// Returns the response body for a completed network request. public func responseBody( for id: Request.ID, diff --git a/Sources/WebInspectorProxyKit/Runtime.swift b/Sources/WebInspectorProxyKit/Runtime.swift index 0f9ba6c4..41d62bc5 100644 --- a/Sources/WebInspectorProxyKit/Runtime.swift +++ b/Sources/WebInspectorProxyKit/Runtime.swift @@ -28,6 +28,28 @@ public enum Runtime { ) } + /// 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 context.withEvents( + domain: .runtime, + buffering: buffering, + isolation: isolation, + extract: { event in + guard case let .runtime(value) = event else { + return nil + } + return value + }, + operation + ) + } + /// Evaluates a JavaScript expression in an execution context. public func evaluate( _ expression: String, diff --git a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift new file mode 100644 index 00000000..cf0358ee --- /dev/null +++ b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift @@ -0,0 +1,444 @@ +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 proxy: WebInspectorProxy + + package init(proxy: WebInspectorProxy) { + self.proxy = proxy + } + + /// The current physical page generation. + public var generation: Generation { + get async throws { + try await proxy.pageGeneration() + } + } + + /// A typed client for DOM protocol commands and events. + public var dom: DOM.Client { + DOM.Client(context: context) + } + + /// A typed client for CSS protocol commands and events. + public var css: CSS.Client { + CSS.Client(context: context) + } + + /// A typed client for Network protocol commands and events. + public var network: Network.Client { + Network.Client(context: context) + } + + /// A typed client for Console protocol commands and events. + public var console: Console.Client { + Console.Client(context: context) + } + + /// A typed client for Runtime protocol commands and events. + public var runtime: Runtime.Client { + Runtime.Client(context: context) + } + + /// A typed client for Page protocol commands. + public var page: Page.Client { + Page.Client(context: context) + } + + private var context: DomainClientContext { + DomainClientContext(proxy: proxy, targetID: .currentPage, route: .currentPage) + } +} + +/// 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/ConnectionEventScopeRegistry.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift new file mode 100644 index 00000000..f71087ab --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift @@ -0,0 +1,187 @@ +import Foundation + +struct ConnectionCapabilityKey: Hashable, Sendable { + var route: RoutingTargetID + var targetID: WebInspectorTarget.ID + var domain: WebInspectorProxyEventDomain +} + +struct ConnectionEventScopeRegistry { + struct Entry: Sendable { + var sink: WebInspectorEventSink? + var capability: ConnectionCapabilityKey + var capacity: Int? + } + + private(set) var entries: [WebInspectorProxyEventScopeID: Entry] = [:] + + var isEmpty: Bool { + entries.isEmpty + } + + mutating func insert( + _ sink: WebInspectorEventSink, + capability: ConnectionCapabilityKey, + capacity: Int?, + generation: WebInspectorPage.Generation + ) { + precondition(entries[sink.id] == nil, "Duplicate Web Inspector event scope identifier.") + entries[sink.id] = Entry(sink: sink, capability: capability, capacity: capacity) + handleInitialDelivery(sink.yieldReset(generation), id: sink.id, capacity: capacity) + } + + 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 leaseIDs: Set = [] + var failedLeaseIDs: Set = [] + var activatedLeaseIDs: Set = [] + var activationWaiters: [WebInspectorProxyEventScopeID: ReplyPromise] = [:] + var releaseWaiters: [WebInspectorProxyEventScopeID: ReplyPromise] = [:] + + var desiredLeaseIDs: Set { + leaseIDs.subtracting(failedLeaseIDs) + } + + var desiredCount: Int { + desiredLeaseIDs.count + } + + var hasActivatedDesiredLease: Bool { + !activatedLeaseIDs.intersection(desiredLeaseIDs).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.leaseIDs.isEmpty, + state.activationWaiters.isEmpty, + state.releaseWaiters.isEmpty else { + return + } + if case .inactive = state.physical { + states.removeValue(forKey: key) + } + } +} 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/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index 3d9e870a..d18f63d3 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -131,26 +131,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() diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index f9d60cf5..fe03e244 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1,5 +1,45 @@ import Foundation +private struct ConnectionCapabilityCommandOperation: Sendable { + let backend: any TransportBackend + let message: String + let promise: ReplyPromise + let pendingKey: TransportSession.PendingKey + let timeoutAction: (@Sendable () async -> Void)? + + func value() async throws { + 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() + } + _ = try await promise.value() + } onCancel: { + Task { + await promise.fulfill(.failure(CancellationError())) + } + } + } +} + +private struct ConnectionCapabilityTask: Sendable { + let task: Task + let pendingKey: TransportSession.PendingKey? +} + +private struct PhysicalTargetDisappearanceWaiters: Sendable { + var activation: [ReplyPromise] = [] + var release: [ReplyPromise] = [] +} + /// Owns one physical inspector connection. /// /// Target membership, command/reply routing, inbound ordering, and terminal @@ -13,6 +53,7 @@ package actor ConnectionCore { package enum TerminalCause: Equatable, Sendable { case explicitClose case fatal(String) + case protocolViolation(String) } private enum State { @@ -34,6 +75,16 @@ package actor ConnectionCore { private var styleSheetRouting: TransportStyleSheetRouting private var runtimeContextRegistry: RuntimeContextRegistry private var eventSubscribers: TransportEventSubscriberRegistry + private var eventScopes: ConnectionEventScopeRegistry + private var capabilities: ConnectionCapabilityRegistry + private var capabilityTasks: [UInt64: ConnectionCapabilityTask] + 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 closeAction: CloseAction private var state: State @@ -63,6 +114,13 @@ package actor ConnectionCore { styleSheetRouting = TransportStyleSheetRouting() runtimeContextRegistry = RuntimeContextRegistry() eventSubscribers = TransportEventSubscriberRegistry() + eventScopes = ConnectionEventScopeRegistry() + capabilities = ConnectionCapabilityRegistry() + capabilityTasks = [:] + currentPageGeneration = WebInspectorPage.Generation(rawValue: 0) + currentPageBindingGapIsOpen = false + eventScopeRegistrationWaiters = [] + eventScopeActivationCancellationAction = nil inboundMessageQueue = TransportInboundMessageQueue() self.closeAction = closeAction ?? { await backend.detach() @@ -80,9 +138,20 @@ package actor ConnectionCore { // deinitializer is only a synchronous backstop for actor-owned local // resources; native resources have their own isolated backstop. eventSubscribers.finishAndRemoveAll() + eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) + let tasks = Array(capabilityTasks.values) + capabilityTasks.removeAll() + for task in tasks { + task.task.cancel() + if let pendingKey = task.pendingKey { + replyStore.removePendingReply(pendingKey) + } + } 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.") } private var isOpen: Bool { @@ -120,6 +189,156 @@ package actor ConnectionCore { return pair.stream } + 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) + + let capacity = buffering.capacity + let mailbox = WebInspectorEventMailbox(capacity: capacity) + let stream = mailbox.makeStream() + + let scopeID = WebInspectorProxyEventScopeID() + let key = ConnectionCapabilityKey(route: route, targetID: targetID, domain: domain) + 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, + capability: key, + capacity: capacity, + generation: generation + ) + resumeEventScopeRegistrationWaitersIfNeeded() + + var capability = capabilities.states[key] + ?? ConnectionCapabilityRegistry.State(physical: .inactive(generation: generation)) + precondition(capability.leaseIDs.insert(scopeID).inserted, "Duplicate capability lease identifier.") + + let activation: ReplyPromise? + if case let .enabled(activeGeneration) = capability.physical, + activeGeneration == generation { + activation = nil + precondition( + capability.activatedLeaseIDs.insert(scopeID).inserted, + "A newly registered capability lease was already activated." + ) + } else { + let promise = ReplyPromise() + capability.activationWaiters[scopeID] = promise + activation = promise + } + capabilities.states[key] = capability + await reconcileCapability(for: key) + + do { + try await withTaskCancellationHandler { + try await activation?.value() + } onCancel: { + Task { [weak self] in + await self?.cancelEventScopeActivation(scopeID, key: key) + } + } + } catch is CancellationError { + let cancellation = CancellationError() + do { + try await releaseEventScope(scopeID) + } catch { + throw WebInspectorScopeError( + operationError: cancellation, + cleanupError: error + ) + } + throw cancellation + } catch { + await abandonEventScopeAfterFailedAcquisition(scopeID, key: key) + throw error + } + + 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 key = entry.capability + guard var capability = capabilities.states[key], + capability.leaseIDs.remove(id) != nil else { + return + } + capability.failedLeaseIDs.remove(id) + capability.activatedLeaseIDs.remove(id) + capability.activationWaiters.removeValue(forKey: id) + + 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[id] = promise + cleanup = promise + case let .enabling(generation, operationID, _): + let promise = ReplyPromise() + capability.releaseWaiters[id] = promise + capability.physical = .enabling( + generation: generation, + operationID: operationID, + mustDisableAfterEnable: true + ) + cleanup = promise + case .disabling: + let promise = ReplyPromise() + capability.releaseWaiters[id] = promise + cleanup = promise + } + capabilities.states[key] = capability + await reconcileCapability(for: key) + try await cleanup?.value() + capabilities.removeEmptyState(for: key) + } + package func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { try Task.checkCancellation() guard isOpen else { @@ -204,6 +423,26 @@ package actor ConnectionCore { } } + 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 replaceEventScopeActivationCancellationActionForTesting( + _ action: @escaping @Sendable () async -> Void + ) { + eventScopeActivationCancellationAction = action + } + package func replaceCloseActionForTesting(_ action: @escaping CloseAction) { precondition(isOpen, "Close action must be installed before closing begins.") closeAction = action @@ -315,6 +554,583 @@ package actor ConnectionCore { return record } + 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 cancelEventScopeActivation( + _ id: WebInspectorProxyEventScopeID, + key: ConnectionCapabilityKey + ) async { + guard var capability = capabilities.states[key], + let waiter = capability.activationWaiters.removeValue(forKey: id) else { + return + } + capability.failedLeaseIDs.insert(id) + capabilities.states[key] = capability + let cancellationAction = eventScopeActivationCancellationAction + eventScopeActivationCancellationAction = nil + await cancellationAction?() + await waiter.fulfill(.failure(CancellationError())) + } + + private func abandonEventScopeAfterFailedAcquisition( + _ id: WebInspectorProxyEventScopeID, + key: ConnectionCapabilityKey + ) async { + eventScopes.remove(id)?.sink?.finish(nil) + resumeEventScopeRegistrationWaitersIfNeeded() + guard var capability = capabilities.states[key] else { + return + } + capability.leaseIDs.remove(id) + capability.failedLeaseIDs.remove(id) + capability.activatedLeaseIDs.remove(id) + capability.activationWaiters.removeValue(forKey: id) + capability.releaseWaiters.removeValue(forKey: id) + capabilities.states[key] = capability + await reconcileCapability(for: key) + capabilities.removeEmptyState(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.activatedLeaseIDs.formUnion(capability.activationWaiters.keys) + let waiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + capabilities.states[key] = capability + for waiter in waiters { + await 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 { + await 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: ConnectionCapabilityCommandOperation + do { + operation = try makeCapabilityCommandOperation(action, for: key) + } 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 + ) + } + capabilityTasks[id] = ConnectionCapabilityTask(task: task, pendingKey: nil) + return + } + + let task = Task { [weak self, operation] in + let result: Result + do { + try await operation.value() + result = .success(()) + } catch { + result = .failure(Self.mapCapabilityError(error, action: action, domain: key.domain)) + } + _ = await self?.completeCapabilityOperation( + id: id, + key: key, + generation: generation, + action: action, + result: result + ) + } + capabilityTasks[id] = ConnectionCapabilityTask( + task: task, + pendingKey: operation.pendingKey + ) + } + + private func makeCapabilityCommandOperation( + _ action: CapabilityWireAction, + for key: ConnectionCapabilityKey + ) throws -> ConnectionCapabilityCommandOperation { + let method: String + switch action { + case .enable: + method = "\(key.domain.rawValue).enable" + case .disable: + method = "\(key.domain.rawValue).disable" + } + 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() + replyStore.insertTargetReply( + TransportSession.PendingReply( + domain: domain, + method: method, + targetID: targetID, + promise: promise, + hasBufferedProvisionalResponse: false + ), + key: replyKey, + rootWrapperID: outerCommandID + ) + + 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 ConnectionCapabilityCommandOperation( + backend: backend, + message: wrapperMessage, + promise: promise, + pendingKey: pendingKey, + timeoutAction: timeoutAction + ) + } + + private func completeCapabilityOperation( + id: UInt64, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + action: CapabilityWireAction, + result: Result + ) async { + if let pendingKey = capabilityTasks.removeValue(forKey: id)?.pendingKey { + replyStore.removePendingReply(pendingKey) + } + 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 + } + capability.activatedLeaseIDs.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 { + await waiter.fulfill(.success(())) + } + for waiter in releaseWaiters { + await waiter.fulfill(.success(())) + } + case let .failure(error): + let activeLeaseFailed = capability.hasActivatedDesiredLease + let wireStateIsKnownInactive = Self.enableFailureProvesInactive(error) + let failedIDs = Set(capability.activationWaiters.keys) + capability.failedLeaseIDs.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 terminalCause: TerminalCause? = if wireStateIsKnownInactive == false + || (activeLeaseFailed && Self.isCommandRejection(error)) { + Self.terminalCauseForUncertainEnableFailure( + error, + domain: key.domain, + wasReenable: activeLeaseFailed + ) + } else { + nil + } + if let terminalCause { + // Claim terminal ownership before resuming any waiter so + // actor reentrancy cannot admit a duplicate enable while + // the failed wire state is unknown. + state = .closing(terminalCause) + } + capabilities.states[key] = capability + for waiter in activationWaiters { + await waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + await waiter.fulfill(.success(())) + } + if let terminalCause { + await finishClaimedTermination(terminalCause) + } + } + + 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 { + await 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.activatedLeaseIDs.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 { + await waiter.fulfill(.success(())) + } + for waiter in releaseWaiters { + await 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.failedLeaseIDs.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 { + await waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + await waiter.fulfill(.success(())) + } + return + } + + let terminalCause = Self.terminalCauseForUncertainDisableFailure( + error, + domain: key.domain + ) + // Claim terminal ownership before resuming any waiter so actor + // reentrancy cannot admit a lease against uncertain wire state. + state = .closing(terminalCause) + 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 { + await waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + await waiter.fulfill(.failure(error)) + } + await finishClaimedTermination(terminalCause) + } + + default: + // Completion from an older generation or superseded operation can + // release its task, but it cannot mutate current physical state. + 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 + } + let method = "\(domain.rawValue).\(action == .enable ? "enable" : "disable")" + 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: domain.rawValue, method: action == .enable ? "enable" : "disable") + } + } + + private nonisolated static func enableFailureProvesInactive( + _ error: any Swift.Error + ) -> Bool { + guard let error = error as? WebInspectorProxyError else { + return false + } + switch error { + case .commandRejected, .pageUnavailable: + return true + case .unsupported, .attachFailed, .closed, .staleIdentifier, + .disconnected, .commandFailed, .protocolViolation, + .eventBufferOverflow, .transportFailure, .timeout: + return false + } + } + + private nonisolated static func isCommandRejection( + _ error: any Swift.Error + ) -> Bool { + guard let error = error as? WebInspectorProxyError, + case .commandRejected = error else { + return false + } + return true + } + + 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)" + ) + } + + 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)" + ) + } + + 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 { let commandID = allocateCommandID() let promise = ReplyPromise() @@ -414,9 +1230,13 @@ package actor ConnectionCore { 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 TransportMessageParser.parse(rawMessage) + } catch { + await terminate(.protocolViolation("Malformed root protocol message.")) + return } await handleRootMessage(parsed) } @@ -486,9 +1306,11 @@ package actor ConnectionCore { if method == "Target.dispatchMessageFromTarget" { guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData) else { + await terminate(.protocolViolation("Malformed Target.dispatchMessageFromTarget payload.")) return } guard let targetMessage = try? await TransportMessageParser.parse(dispatch.message) else { + await terminate(.protocolViolation("Malformed target protocol message.")) return } await handleTargetMessage(targetMessage, targetID: dispatch.targetId) @@ -500,12 +1322,18 @@ package actor ConnectionCore { let destroyedCurrentMainPageTarget = method == "Target.targetDestroyed" && targetID != nil && targetID == targetRegistry.currentMainPageTargetID - let pendingStyleSheetAddedEvents = await updateRegistryFromRootEvent( - method: method, - targetID: targetID, - sourceTargetID: sourceTargetID, - paramsData: parsed.paramsData - ) + let pendingStyleSheetAddedEvents: [ResolvedStyleSheetAddedEvent] + do { + pendingStyleSheetAddedEvents = try await updateRegistryFromRootEvent( + method: method, + targetID: targetID, + sourceTargetID: sourceTargetID, + paramsData: parsed.paramsData + ) + } catch { + await terminate(.protocolViolation("Failed to decode \(method): \(error)")) + return + } await emit( domain: ProtocolDomain(method: method), method: method, @@ -540,6 +1368,7 @@ package actor ConnectionCore { if method == "Target.dispatchMessageFromTarget" { guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData), let targetMessage = try? await TransportMessageParser.parse(dispatch.message) else { + await terminate(.protocolViolation("Malformed nested Target.dispatchMessageFromTarget payload.")) return } await handleTargetMessage(targetMessage, targetID: dispatch.targetId) @@ -599,24 +1428,18 @@ package actor ConnectionCore { targetID: ProtocolTarget.ID?, sourceTargetID: ProtocolTarget.ID?, paramsData: Data - ) async -> [ResolvedStyleSheetAddedEvent] { + ) async throws -> [ResolvedStyleSheetAddedEvent] { 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 await applyTargetCreated(record(for: params.targetInfo)) case "Target.targetDestroyed": - guard let params = try? TransportMessageParser.decode(TargetDestroyedParams.self, from: paramsData) else { - return [] - } + let params = try TransportMessageParser.decode(TargetDestroyedParams.self, from: paramsData) await applyTargetDestroyed(params.targetId) return [] 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 await applyTargetCommitted(oldTargetID: params.oldTargetId, newTargetID: params.newTargetId) case "Runtime.executionContextCreated", "Runtime.executionContextDestroyed", "Runtime.executionContextsCleared": updateRegistryFromTargetEvent( method: method, @@ -684,8 +1507,14 @@ package actor ConnectionCore { } } - private func applyTargetCreated(_ record: ProtocolTarget.Record) -> [ResolvedStyleSheetAddedEvent] { - resolvePendingStyleSheets(for: targetRegistry.recordTargetCreated(record)) + private func applyTargetCreated(_ record: ProtocolTarget.Record) async -> [ResolvedStyleSheetAddedEvent] { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID + let resolution = targetRegistry.recordTargetCreated(record) + await currentPageBindingDidChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) + return resolvePendingStyleSheets(for: resolution) } private func record(for targetInfo: TargetInfoPayload) -> ProtocolTarget.Record { @@ -711,35 +1540,147 @@ package actor ConnectionCore { } private func applyTargetDestroyed(_ targetID: ProtocolTarget.ID) async { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID targetRegistry.removeTarget(targetID) + let capabilityWaiters = physicalTargetDidDisappear(targetID) + let pendingReplies = replyStore.removeTargetReplies(for: targetID) provisionalTargetMessageStore.removeTarget(targetID) styleSheetRouting.removeTarget(targetID) runtimeContextRegistry.removeTarget(targetID) - let pendingReplies = replyStore.removeTargetReplies(for: targetID) + await currentPageBindingDidChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) + await resumePhysicalTargetDisappearanceWaiters(capabilityWaiters) for pending in pendingReplies { await pending.promise.fulfill(.failure(TransportSession.Error.missingTarget(targetID))) } } private func applyTargetCommitted( - oldTargetID: ProtocolTarget.ID?, + oldTargetID: ProtocolTarget.ID, newTargetID: ProtocolTarget.ID - ) -> [ResolvedStyleSheetAddedEvent] { + ) async -> [ResolvedStyleSheetAddedEvent] { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID let mutation = targetRegistry.commitTarget(oldTargetID: oldTargetID, newTargetID: newTargetID) - if let committedOldTargetID = mutation.committedOldTargetID { - moveBufferedProvisionalTargetMessages(from: committedOldTargetID, to: newTargetID) - } - - if mutation.shouldRetargetExternalState, - let oldTargetID = mutation.committedOldTargetID { - replyStore.retargetPendingReplies(from: oldTargetID, to: newTargetID) + var capabilityWaiters = PhysicalTargetDisappearanceWaiters() + var stalePendingReplies: [TransportSession.PendingReply] = [] + + if mutation.shouldRetargetExternalState { + let oldTargetID = mutation.committedOldTargetID + capabilityWaiters = physicalTargetDidDisappear(oldTargetID) + stalePendingReplies = replyStore.removeTargetReplies(for: oldTargetID) + provisionalTargetMessageStore.removeTarget(oldTargetID) styleSheetRouting.retarget(from: oldTargetID, to: newTargetID) runtimeContextRegistry.retarget(oldTargetID: oldTargetID, newTargetID: newTargetID) } + await currentPageBindingDidChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) + await resumePhysicalTargetDisappearanceWaiters(capabilityWaiters) + for pending in stalePendingReplies { + await pending.promise.fulfill( + .failure(TransportSession.Error.missingTarget(mutation.committedOldTargetID)) + ) + } + return resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) } + private func currentPageBindingDidChange( + from oldTargetID: ProtocolTarget.ID?, + to newTargetID: ProtocolTarget.ID? + ) async { + guard oldTargetID != newTargetID else { + return + } + + 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 } + for key in keys { + await reconcileCapability(for: key) + } + return + } + + currentPageBindingGapIsOpen = oldTargetID != nil && newTargetID == nil + currentPageGeneration = WebInspectorPage.Generation( + rawValue: currentPageGeneration.rawValue &+ 1 + ) + eventScopes.publishReset(currentPageGeneration) { sink in + sink.route == .currentPage + } + + 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 + } + + if newTargetID != nil { + for key in keys { + await reconcileCapability(for: key) + } + } + for waiter in releaseWaiters { + await waiter.fulfill(.success(())) + } + } + + 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.failedLeaseIDs.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 + ) async { + for waiter in waiters.activation { + await waiter.fulfill(.failure(WebInspectorProxyError.pageUnavailable)) + } + for waiter in waiters.release { + await waiter.fulfill(.success(())) + } + } + private func resolvePendingStyleSheets( for frameTarget: TransportFrameTargetResolution? ) -> [ResolvedStyleSheetAddedEvent] { @@ -749,13 +1690,6 @@ package actor ConnectionCore { 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 { @@ -944,6 +1878,84 @@ package actor ConnectionCore { paramsData: paramsData, destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget ) + if let eventDomain = webInspectorEventDomain(for: domain) { + var terminalViolation: String? + let targetSnapshot = snapshot() + let sinks = eventScopes.sinks(for: eventDomain).filter { sink in + LiveWebInspectorProxyBackend.shouldDeliver( + envelope, + to: sink.route, + in: targetSnapshot + ) + } + var projectedEvents: [ConnectionCapabilityKey: WebInspectorProxyEvent] = [:] + do { + if sinks.isEmpty { + let targetID = WebInspectorTarget.ID.currentPage + _ = try LiveProxyEventDecoder.proxyEvent( + from: envelope, + targetID: targetID, + lifecycleTarget: LiveWebInspectorProxyBackend.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: LiveWebInspectorProxyBackend.lifecycleTarget( + for: envelope, + route: sink.route, + targetID: sink.targetID, + in: targetSnapshot + ) + ) + projectedEvent = LiveWebInspectorProxyBackend.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 { + await terminate(.protocolViolation(terminalViolation)) + return + } + } for continuation in eventSubscribers.continuations(for: domain) { continuation.yield(envelope) } @@ -953,6 +1965,29 @@ package actor ConnectionCore { await notifyMainPageTargetWaitersIfNeeded(receivedSequence: eventSequence.sequence) } + 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 + } + } + private func notifyMainPageTargetWaitersIfNeeded(receivedSequence: UInt64) async { guard let currentMainPageTargetID = targetRegistry.currentMainPageTargetID, !mainPageTargetWaiterStore.isEmpty else { @@ -1024,6 +2059,19 @@ package actor ConnectionCore { .transportClosed case let .closing(.fatal(message)), let .closed(.fatal(message)): .transportFailure(message) + case let .closing(.protocolViolation(message)), let .closed(.protocolViolation(message)): + .transportFailure(message) + } + } + + private var terminalScopeError: any Swift.Error { + switch state { + case .open, .closing(.explicitClose), .closed(.explicitClose): + WebInspectorProxyError.closed + case let .closing(.fatal(message)), let .closed(.fatal(message)): + WebInspectorProxyError.transportFailure(message) + case let .closing(.protocolViolation(message)), let .closed(.protocolViolation(message)): + WebInspectorProxyError.protocolViolation(message) } } @@ -1038,7 +2086,40 @@ package actor ConnectionCore { return } + await finishClaimedTermination(cause) + } + + private func finishClaimedTermination(_ cause: TerminalCause) async { let transportError = terminalTransportError + let scopeError: (any Swift.Error)? = cause == .explicitClose ? nil : terminalScopeError + if let scopeError { + eventScopes.finishSubscribers(with: 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 runningCapabilityTasks = capabilityTasks.values.map(\.task) + capabilityTasks.removeAll() + for task in runningCapabilityTasks { + task.cancel() + } + let eventScopeWaiters = eventScopeRegistrationWaiters + eventScopeRegistrationWaiters.removeAll() + for waiter in eventScopeWaiters { + waiter.continuation.resume() + } for pending in replyStore.pendingReplies { await pending.promise.fulfill(.failure(transportError)) } @@ -1048,7 +2129,23 @@ package actor ConnectionCore { replyStore.removeAll() provisionalTargetMessageStore.removeAll() inboundMessageQueue = TransportInboundMessageQueue() - eventSubscribers.finishAndRemoveAll() + if cause != .explicitClose { + eventSubscribers.finishAndRemoveAll() + } + + for waiter in activationWaiters { + await waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) + } + for waiter in releaseWaiters { + if let scopeError { + await waiter.fulfill(.failure(scopeError)) + } else { + await waiter.fulfill(.success(())) + } + } + for task in runningCapabilityTasks { + await task.value + } let closeAction = self.closeAction await closeAction() @@ -1060,6 +2157,10 @@ package actor ConnectionCore { preconditionFailure("ConnectionCore terminal state changed outside terminate(_:).") } state = .closed(cause) + if cause == .explicitClose { + eventScopes.finishSubscribers(with: nil) + eventSubscribers.finishAndRemoveAll() + } resumeCloseWaiters(with: terminalResult(for: cause)) } @@ -1069,6 +2170,8 @@ package actor ConnectionCore { .success(()) case let .fatal(message): .failure(WebInspectorProxyError.disconnected(message)) + case let .protocolViolation(message): + .failure(WebInspectorProxyError.protocolViolation(message)) } } @@ -1146,7 +2249,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..1c3a3d55 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,38 +152,12 @@ 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 { - return nil - } - return currentMainPageTargetID - } - - guard targetsByID[newTargetID] == nil else { - return nil - } - - let provisionalTargetIDs = targetsByID - .filter { $0.value.isProvisional } - .map(\.key) - return provisionalTargetIDs.count == 1 ? provisionalTargetIDs[0] : nil - } - private func committedFrameTargetResolution( for record: ProtocolTarget.Record ) -> TransportFrameTargetResolution? { @@ -213,7 +181,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/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index 7d2657a6..8d85f5ea 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -64,6 +64,15 @@ public actor WebInspectorProxy { private let backend: (any WebInspectorProxyBackend)? 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 @@ -248,6 +257,10 @@ public actor WebInspectorProxy { await core.waitForCloseWaiterForTesting() } + package func pageGeneration() async throws -> WebInspectorPage.Generation { + try await core.pageGeneration() + } + package func dispatchCommand( targetID: WebInspectorTarget.ID, route: RoutingTargetID, diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift index 118c99b6..aed0fdf3 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift @@ -72,4 +72,38 @@ package protocol WebInspectorProxyBackend: Sendable { targetID: WebInspectorTarget.ID, domain: WebInspectorProxyEventDomain ) async + + func acquireEventScope( + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) async throws -> WebInspectorProxyEventScope + + func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws +} + +package extension WebInspectorProxyBackend { + func acquireEventScope( + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + 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..5a18065e 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift @@ -11,12 +11,30 @@ 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) + + /// 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/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index 17c4d87b..cc542464 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -218,6 +218,30 @@ package struct DomainClientContext: Sendable { ) } + 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 { + guard let backend = proxy.structuredEventBackend else { + throw unimplementedCommand(domain: domain.rawValue, method: "withEvents") + } + return try await withWebInspectorEventScope( + backend: backend, + targetID: targetID, + route: route, + domain: domain, + buffering: buffering, + isolation: isolation, + extract: extract, + operation + ) + } + package func domEvents() -> AsyncStream { proxy.domEvents(targetID: targetID, route: route) } diff --git a/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift b/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift index 2a75cbb3..b6b877af 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: 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/WebInspectorProxyKitTests/TransportReplyStoreTests.swift b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift index 1d7a352e..047dc883 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift @@ -3,26 +3,24 @@ 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) store.insertTargetReply( pendingReply(targetID: .init("frame-old")), 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(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) @@ -31,11 +29,11 @@ func replyStoreUsesCommandIndexForRetargetedTimeout() { 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-new")) + #expect(removed?.targetID == ProtocolTarget.ID("frame-old")) #expect(store.pendingTargetReplyKeys.isEmpty) + #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) } @Test diff --git a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift index f9aed4ee..f5c4ea62 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, @@ -527,13 +527,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,28 +609,24 @@ func subframeCommitDoesNotConsumeCurrentMainPageTarget() async throws { } @Test -func targetCommitRetargetsPendingRepliesToCommittedTarget() async throws { +func targetCommitFailsPendingRepliesForRemovedOldTarget() 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":"frame-provisional","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 + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) - #expect(result.targetID == ProtocolTarget.ID("frame-committed")) + await #expect(throws: TransportSession.Error.missingTarget(.init("page-main"))) { + try await sendTask.value + } #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) } @@ -638,11 +634,12 @@ func targetCommitRetargetsPendingRepliesToCommittedTarget() async throws { 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 +647,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 +675,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 +688,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,45 +696,16 @@ 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() @@ -768,36 +737,7 @@ func provisionalTargetMessagesAreDispatchedAfterCommitTargetEvent() async throws } @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 removedTargetPendingReplyFailsAtCommitBeforeTimeout() async throws { let backend = FakeTransportBackend() let responseTimeout = ManualResponseTimeout() let session = TransportSession( @@ -807,20 +747,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: TransportSession.Error.missingTarget(.init("page-main"))) { try await sendTask.value } #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) @@ -832,8 +772,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) @@ -855,7 +795,7 @@ func rootScopedRuntimeDOMAndConsoleEventsResolveToCurrentPageTarget() async thro 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"}}}"#) + await session.receiveRootMessage(#"{"method":"Console.messageAdded","params":{"message":{"source":"javascript","level":"log","text":"hello"}}}"#) let snapshot = await session.snapshot() #expect(snapshot.executionContextsByKey[contextKey("page-main", 11)]?.targetID == ProtocolTarget.ID("page-main")) @@ -1059,7 +999,7 @@ func domainStreamsReceiveIndependentTargetEventsInOrder() async throws { 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("frame-A"), message: #"{"method":"Console.messageAdded","params":{"message":{"source":"javascript","level":"log","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() @@ -1081,7 +1021,7 @@ func rootCSSStyleSheetEventsResolveFrameTargetFromFrameIDAndStyleSheetOwnership( 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.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-frame","frameId":"frame-A","origin":"author"}}}"#) 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"}}"#) @@ -1104,7 +1044,7 @@ func rootCSSStyleSheetAddedBeforeFrameTargetDoesNotPinSheetToPage() async throws let cssEvents = ProtocolEventRecorder(stream: cssStream) 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":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-late-frame","frameId":"late-frame","origin":"author"}}}"#) 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"}}"#) @@ -1125,7 +1065,7 @@ func rootCSSStyleSheetAddedBeforeProvisionalFrameTargetReplaysAfterCommit() asyn let cssEvents = ProtocolEventRecorder(stream: cssStream) 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":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-provisional-frame","frameId":"ad-frame","origin":"author"}}}"#) 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"}}"#) diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 2eee0373..84277a51 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -644,6 +644,50 @@ func droppingOpenProxyHandleReachesConnectionCoreDeinit() { #expect(weakProxy == nil) } +@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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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() @@ -2193,170 +2237,1970 @@ func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries( #expect(entries[1].value.value == .number(42)) } -private func pageTarget(proxy: WebInspectorProxy) -> WebInspectorTarget { - WebInspectorTarget( - id: WebInspectorTarget.ID("page-main"), - kind: .page, - frameID: FrameID("main-frame"), - isProvisional: false, - proxy: proxy, - route: RoutingTargetID("page-main") +@Test +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 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) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"enable-replay","timestamp":1}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" ) -} -private func installPageTarget( - in transport: TransportSession, - targetID: ProtocolTarget.ID = ProtocolTarget.ID("page-main"), - frameID: String? = "main-frame" -) async { - let targetID = jsonEscapedString(targetID.rawValue) - let frameIDField = frameID.map { - #","frameId":"\#(jsonEscapedString($0))""# - } ?? "" - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"\#(targetID)","type":"page"\#(frameIDField),"isProvisional":false}}}"# + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) -} -private func waitForEventSubscription( - _ target: WebInspectorTarget, - domain: WebInspectorProxyEventDomain -) async { - await target.proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: domain) + 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 + } + 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")) } -private func receiveTargetEvent( - _ transport: TransportSession, - targetID: ProtocolTarget.ID, - method: String, - params: String -) async { - await transport.receiveRootMessage(targetDispatchMessage( - targetID: targetID, - message: #"{"method":"\#(method)","params":\#(params)}"# - )) -} +@Test +func structuredNetworkScopePreservesFrameScopedIdentifiers() 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) -private func waitForTargetMessage( - _ backend: FakeTransportBackend, - method: String, - ordinal: Int = 0, - after count: Int = 0 -) async throws -> SentTargetMessage { - try await withThrowingTaskGroup(of: SentTargetMessage.self) { group in - defer { - group.cancelAll() + let scopeTask = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() } + } - group.addTask { - try await backend.waitForTargetMessage(method: method, ordinal: ordinal, after: count) - } - group.addTask { - try await Task.sleep(for: transportCommandBackendWaitTimeout) - throw WebInspectorProxyError.timeout(domain: "test", method: method) - } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetEvent( + transport, + targetID: ProtocolTarget.ID("frame-target"), + method: "Network.requestWillBeSent", + 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: "{}" + ) - guard let message = try await group.next() else { - throw WebInspectorProxyError.timeout(domain: "test", method: method) - } - return message + 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.id.targetScopeRawValue == "frame-target") } -private func receiveTargetReply( - _ transport: TransportSession, - targetID: ProtocolTarget.ID, - messageID: UInt64, - result: String -) async { - await transport.receiveRootMessage(targetDispatchMessage( - targetID: targetID, - message: #"{"id":\#(messageID),"result":\#(result)}"# - )) -} +@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 + } + } -private func targetDispatchMessage( - targetID: ProtocolTarget.ID, - message: String -) -> String { - let escapedTargetID = jsonEscapedString(targetID.rawValue) - let escapedMessage = jsonEscapedString(message) - return #"{"method":"Target.dispatchMessageFromTarget","params":{"targetId":"\#(escapedTargetID)","message":"\#(escapedMessage)"}}"# -} + 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) + } + } -private func jsonEscapedString(_ string: String) -> String { - string - .replacingOccurrences(of: #"\"#, with: #"\\"#) - .replacingOccurrences(of: #"""#, with: #"\""#) - .replacingOccurrences(of: "\n", with: #"\n"#) - .replacingOccurrences(of: "\r", with: #"\r"#) - .replacingOccurrences(of: "\t", with: #"\t"#) -} + await secondGate.waitUntilStarted() + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"future","timestamp":2}"# + ) + await secondGate.release() -private func messageID(_ message: String) throws -> UInt64 { - let object = try messageObject(message) - if let number = object["id"] as? NSNumber { - return number.uint64Value + let (secondReset, secondEvent) = try await throwingValue(of: secondScope) + guard case .reset? = secondReset else { + Issue.record("Expected the late scope's generation reset.") + return } - if let string = object["id"] as? String, - let id = UInt64(string) { - return id + guard case let .event(_, .loadingFinished(id, _, _, _))? = secondEvent else { + Issue.record("Expected the late scope's future event.") + return } - throw TransportSession.Error.malformedMessage -} - -private func messageMethod(_ message: String) throws -> String? { - try messageObject(message)["method"] as? String -} + #expect(id == Network.Request.ID("future")) -private func messageParameters(_ message: String) throws -> [String: Any] { - try messageObject(message)["params"] as? [String: Any] ?? [:] -} + 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) -private func messageObject(_ message: String) throws -> [String: Any] { - let data = try #require(message.data(using: .utf8)) - return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + 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) } -private actor CloseConnectionGate { - private var started = false - private var released = false - private var startWaiters: [CheckedContinuation] = [] - private var releaseContinuation: CheckedContinuation? - - func waitUntilReleased() async { - started = true - let waiters = startWaiters - startWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - guard released == false else { - return - } - await withCheckedContinuation { continuation in - releaseContinuation = continuation +@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 } - func waitUntilStarted() async { - guard started == false else { - return - } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } + 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: "{}" + ) - func release() { - released = true - releaseContinuation?.resume() - releaseContinuation = nil - } + #expect(try await value(of: scopeTask) == .cancelled) + #expect(await completionProbe.isFinished()) + #expect(await bodyProbe.isFinished() == false) } -private actor CompletionProbe { - private var finished = 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 legacyEventStreamFinishesOnlyAfterExplicitCloseCompletes() async throws { + let backend = FakeTransportBackend() + let closeGate = CloseConnectionGate() + let transport = TransportSession( + backend: backend, + responseTimeout: .milliseconds(750), + closeAction: { + await closeGate.waitUntilReleased() + } + ) + let stream = await transport.events(for: .network) + let nextStarted = CloseConnectionGate() + let nextCompletion = CompletionProbe() + let nextTask = Task { + var iterator = stream.makeAsyncIterator() + await nextStarted.waitUntilReleased() + let event = await iterator.next() + await nextCompletion.finish() + return event + } + await nextStarted.waitUntilStarted() + await nextStarted.release() + await Task.yield() + + let closeTask = Task { + await transport.close() + } + await closeGate.waitUntilStarted() + await Task.yield() + + #expect(await nextCompletion.isFinished() == false) + + await closeGate.release() + await closeTask.value + #expect(await nextTask.value == nil) + #expect(await nextCompletion.isFinished()) +} + +@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() + + await transport.failFromNativeCallback("native fatal") + + #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}"# + ) + + #expect(await scopeTask.value == .protocolViolation) +} + +@Test +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) + + 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) + + 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: 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: "{}" + ) + + guard case let .event(_, .unknown(rawEvent))? = try await throwingValue(of: scopeTask) else { + Issue.record("Expected an unknown Network event.") + return + } + #expect(rawEvent.domain == "Network") + #expect(rawEvent.method == "futureEvent") +} + +@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 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 transport.receiveRootMessage("{") + + #expect(await scopeTask.value == .protocolViolation) +} + +@Test +func structuredNetworkScopePreservesPendingEventBeforeRetargetResetAndNewReplay() 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 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) + } + } + + 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: 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 (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(eventGeneration == replacementGeneration) + #expect(id == Network.Request.ID("replacement-replay")) +} + +@Test +func structuredNetworkScopeCoalescesConsecutiveDirectReplacementResetsWithoutConsumingCapacity() 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 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) + } + } + + 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: 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: "{}" + ) + + 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 structuredNetworkScopeSurvivesReplacementDestroyedDuringReenable() 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 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) + } + } + + 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: 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: "{}" + ) + + 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 structuredNetworkScopeCoalescesDestroyCreateIntoOneReplacementReset() 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 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) + } + } + + 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: 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 (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(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")) +} + +@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)) + } + } + + 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 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 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":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# + ) + let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 bodyGate = CloseConnectionGate() + + 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)) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + #expect(enable.targetIdentifier == ProtocolTarget.ID("frame-target")) + await receiveTargetReply( + transport, + 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"}}"# + ) + + #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 structuredPhysicalTargetScopeFailsAcquisitionWhenTargetDiesDuringEnable() 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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() + + 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)) + } + } + + 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"}}"# + ) + + #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 structuredFixedTargetScopeFailsAtCommitWithoutRetargetingPendingEnable() 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":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 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)) + } + } + + 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"}}"# + ) + + #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 structuredNetworkScopeLateAcquireDuringReenableDoesNotDisableLiveLease() 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 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 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: replacementEnable.targetIdentifier, + messageID: try messageID(replacementEnable.message), + result: "{}" + ) + await lateGate.waitUntilStarted() + _ = try await throwingValue(of: firstScope) + + 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 structuredNetworkScopeLateAcquireDuringDisableReenablesAfterCleanup() 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 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 firstEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: firstEnable.targetIdentifier, + messageID: try messageID(firstEnable.message), + result: "{}" + ) + await firstGate.waitUntilStarted() + + await firstGate.release() + let firstDisable = try await waitForTargetMessage(backend, method: "Network.disable") + 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: firstDisable.targetIdentifier, + messageID: try messageID(firstDisable.message), + result: "{}" + ) + _ = try await throwingValue(of: firstScope) + + 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 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 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: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await stalledGate.waitUntilStarted() + await peerGate.waitUntilStarted() + + 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 { + WebInspectorTarget( + id: WebInspectorTarget.ID("page-main"), + kind: .page, + frameID: FrameID("main-frame"), + isProvisional: false, + proxy: proxy, + route: RoutingTargetID("page-main") + ) +} + +private func installPageTarget( + in transport: TransportSession, + targetID: ProtocolTarget.ID = ProtocolTarget.ID("page-main"), + frameID: String? = "main-frame" +) async { + let targetID = jsonEscapedString(targetID.rawValue) + let frameIDField = frameID.map { + #","frameId":"\#(jsonEscapedString($0))""# + } ?? "" + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"\#(targetID)","type":"page"\#(frameIDField),"isProvisional":false}}}"# + ) +} + +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, + method: String, + params: String +) async { + await transport.receiveRootMessage(targetDispatchMessage( + targetID: targetID, + message: #"{"method":"\#(method)","params":\#(params)}"# + )) +} + +private func waitForTargetMessage( + _ backend: FakeTransportBackend, + method: String, + ordinal: Int = 0, + after count: Int = 0 +) async throws -> SentTargetMessage { + try await withThrowingTaskGroup(of: SentTargetMessage.self) { group in + defer { + group.cancelAll() + } + + group.addTask { + try await backend.waitForTargetMessage(method: method, ordinal: ordinal, after: count) + } + group.addTask { + try await Task.sleep(for: transportCommandBackendWaitTimeout) + throw WebInspectorProxyError.timeout(domain: "test", method: method) + } + + guard let message = try await group.next() else { + throw WebInspectorProxyError.timeout(domain: "test", method: method) + } + return message + } +} + +private func receiveTargetReply( + _ transport: TransportSession, + targetID: ProtocolTarget.ID, + messageID: UInt64, + result: String +) async { + await transport.receiveRootMessage(targetDispatchMessage( + targetID: targetID, + message: #"{"id":\#(messageID),"result":\#(result)}"# + )) +} + +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 +) -> 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: #"\""#) + .replacingOccurrences(of: "\n", with: #"\n"#) + .replacingOccurrences(of: "\r", with: #"\r"#) + .replacingOccurrences(of: "\t", with: #"\t"#) +} + +private func messageID(_ message: String) throws -> UInt64 { + let object = try messageObject(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 messageMethod(_ message: String) throws -> String? { + try messageObject(message)["method"] as? String +} + +private func messageParameters(_ message: String) throws -> [String: Any] { + try messageObject(message)["params"] as? [String: Any] ?? [:] +} + +private func messageObject(_ message: String) throws -> [String: Any] { + let data = try #require(message.data(using: .utf8)) + 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 + private var startWaiters: [CheckedContinuation] = [] + private var releaseContinuation: CheckedContinuation? + + func waitUntilReleased() async { + started = true + let waiters = startWaiters + startWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + guard released == false else { + return + } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilStarted() async { + guard started == false else { + return + } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor CompletionProbe { + private var finished = false func finish() { finished = true @@ -2414,6 +4258,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) From 7ddae652d77b126cc348db9e970e2e9c80e6cb7a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:47:45 +0900 Subject: [PATCH 10/81] refactor(proxy): move event projection into connection core --- .../LiveWebInspectorProxyBackend.swift | 571 +----------------- .../Transport/ConnectionEventProjection.swift | 569 +++++++++++++++++ .../Transport/TransportSession.swift | 8 +- ...torProxyTransportCommandBackendTests.swift | 93 +++ 4 files changed, 669 insertions(+), 572 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index fd2958fc..a0fa9b0e 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -162,102 +162,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { private nonisolated func shouldDeliver(_ event: ProtocolEvent, to route: RoutingTargetID) async -> Bool { let snapshot = await transport.snapshot() - return Self.shouldDeliver(event, to: route, in: snapshot) - } - - 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 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 Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .inspector: - return Self.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 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 + return ConnectionEventProjection.shouldDeliver(event, to: route, in: snapshot) } private nonisolated func projectedEvent( @@ -266,446 +171,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { route: RoutingTargetID ) async -> WebInspectorProxyEvent { let snapshot = await transport.snapshot() - return Self.projectedEvent(proxyEvent, from: event, route: route, in: snapshot) - } - - 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 - } - if event.domain == .inspector, - targetID == snapshot.currentMainPageTargetID { - return Self.mainPageInspectorEvent(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 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 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(target: WebInspectorTarget.ID(targetScopeRawValue)) - 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) + return ConnectionEventProjection.projectedEvent(proxyEvent, from: event, route: route, in: snapshot) } private nonisolated func lifecycleTarget( @@ -714,7 +180,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { targetID: WebInspectorTarget.ID ) async -> WebInspectorLifecycleTarget? { let snapshot = await transport.snapshot() - return Self.lifecycleTarget( + return ConnectionEventProjection.lifecycleTarget( for: event, route: route, targetID: targetID, @@ -722,37 +188,6 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - 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 - } - } } private func protocolDomain(for domain: WebInspectorProxyEventDomain) -> ProtocolDomain { diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift new file mode 100644 index 00000000..93740e8e --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift @@ -0,0 +1,569 @@ +import Foundation + +package enum ConnectionEventProjection { + 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 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 Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + case .inspector: + return Self.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 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 + } + if event.domain == .inspector, + targetID == snapshot.currentMainPageTargetID { + return Self.mainPageInspectorEvent(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 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 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(target: WebInspectorTarget.ID(targetScopeRawValue)) + 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/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index fe03e244..72a9ff5a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1882,7 +1882,7 @@ package actor ConnectionCore { var terminalViolation: String? let targetSnapshot = snapshot() let sinks = eventScopes.sinks(for: eventDomain).filter { sink in - LiveWebInspectorProxyBackend.shouldDeliver( + ConnectionEventProjection.shouldDeliver( envelope, to: sink.route, in: targetSnapshot @@ -1895,7 +1895,7 @@ package actor ConnectionCore { _ = try LiveProxyEventDecoder.proxyEvent( from: envelope, targetID: targetID, - lifecycleTarget: LiveWebInspectorProxyBackend.lifecycleTarget( + lifecycleTarget: ConnectionEventProjection.lifecycleTarget( for: envelope, route: .currentPage, targetID: targetID, @@ -1917,14 +1917,14 @@ package actor ConnectionCore { let decodedEvent = try LiveProxyEventDecoder.proxyEvent( from: envelope, targetID: sink.targetID, - lifecycleTarget: LiveWebInspectorProxyBackend.lifecycleTarget( + lifecycleTarget: ConnectionEventProjection.lifecycleTarget( for: envelope, route: sink.route, targetID: sink.targetID, in: targetSnapshot ) ) - projectedEvent = LiveWebInspectorProxyBackend.projectedEvent( + projectedEvent = ConnectionEventProjection.projectedEvent( decodedEvent, from: envelope, route: sink.route, diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 84277a51..d58062db 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -2337,6 +2337,99 @@ func structuredNetworkScopePreservesFrameScopedIdentifiers() async throws { #expect(request.id.targetScopeRawValue == "frame-target") } +@Test +func structuredRuntimeScopePreservesFrameScopedIdentifiers() 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.runtime.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() + } + } + + 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() From 6a705e84f16265eeb29976d5c9c9487c12388642 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:05:32 +0900 Subject: [PATCH 11/81] fix(proxy): quiesce native callbacks during close --- .../NativeInspectablePage.swift | 6 +- .../Transport/TransportReceiver.swift | 110 +++- .../Transport/TransportSession.swift | 328 +++++++++--- .../NativeAttachmentLifecycleTests.swift | 94 +++- .../TransportReceiverLifecycleTests.swift | 474 ++++++++++++++++++ ...torProxyTransportCommandBackendTests.swift | 3 +- 6 files changed, 918 insertions(+), 97 deletions(-) create mode 100644 Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift index c44dcf59..24114fad 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift @@ -66,7 +66,7 @@ package enum NativeConnectionCoreFactory { } else if let attachment { await attachment.close() } else { - receiver.close() + await receiver.close() page.restoreInspectabilityIfNeeded() } throw error @@ -96,7 +96,7 @@ package final class NativeAttachment { return } isClosed = true - receiver.close() + await receiver.close() await backend.detach() page.restoreInspectabilityIfNeeded() } @@ -105,7 +105,7 @@ package final class NativeAttachment { guard !isClosed else { return } - receiver.close() + receiver.closeSynchronously() backend.detachSynchronously() page.restoreInspectabilityIfNeeded() } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 9e6e8acb..0929f939 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -16,6 +16,12 @@ package final class TransportReceiver: Sendable { var isDraining = false var generation: UInt64 = 0 var isClosed = false + var closeWaiters: [CheckedContinuation] = [] + } + + private enum DrainStep: Sendable { + case deliver(core: ConnectionCore, message: String) + case stop(closeWaiters: [CheckedContinuation]) } private let state = Mutex(State()) @@ -67,55 +73,117 @@ package final class TransportReceiver: Sendable { } } - package func close() { - state.withLock { - $0.isClosed = true - $0.generation &+= 1 - $0.core.value = 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 + Self.seal(&state) + guard state.isDraining else { + return [continuation] + } + state.closeWaiters.append(continuation) + return [] + } + Self.resume(readyWaiters) + } + } + + /// 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 + Self.seal(&state) + return Self.takeCloseWaitersIfQuiescent(from: &state) } + Self.resume(readyWaiters) + } + + package func closeWaiterCountForTesting() -> Int { + state.withLock { $0.closeWaiters.count } } - package func fail(_ message: String) { - let core = state.withLock { state in + @discardableResult + package func fail(_ message: String) -> Task? { + let result: ( + core: ConnectionCore?, + readyWaiters: [CheckedContinuation] + ) = state.withLock { state in guard !state.isClosed else { - return nil as ConnectionCore? + return (nil, []) } - return state.core.value + let core = state.core.value + Self.seal(&state) + return (core, Self.takeCloseWaitersIfQuiescent(from: &state)) } - Task { [weak core] in - await core?.failFromNativeCallback(message) + Self.resume(result.readyWaiters) + 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.core.receiveRootMessage(next.message) + while true { + switch nextDrainStep(generation: generation) { + case let .deliver(core, message): + await core.receiveRootMessage(message) + case let .stop(closeWaiters): + Self.resume(closeWaiters) + return + } } } - private func nextMessage(generation: UInt64) -> (core: ConnectionCore, 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 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 (core, message) + return .deliver(core: core, message: message) + } + } + + private static func seal(_ state: inout State) { + guard !state.isClosed else { + return + } + state.isClosed = true + state.generation &+= 1 + state.core.value = nil + state.messages.removeAll(keepingCapacity: false) + state.messageStartIndex = 0 + } + + 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() } } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 72a9ff5a..ee38f9a3 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization private struct ConnectionCapabilityCommandOperation: Sendable { let backend: any TransportBackend @@ -49,6 +50,7 @@ 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 @@ -58,14 +60,73 @@ package actor ConnectionCore { private enum State { case open - case closing(TerminalCause) - case closed(TerminalCause) + 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: [ReplyPromise] + let mainPageTargetWaiters: [ReplyPromise] + let activationWaiters: [ReplyPromise] + let releaseWaiters: [ReplyPromise] + let capabilityTasks: [Task] + let closeAction: CloseAction + + func run() async { + for pending in pendingReplies { + await pending.fulfill(.failure(transportError)) + } + for waiter in mainPageTargetWaiters { + await waiter.fulfill(.failure(transportError)) + } + for waiter in activationWaiters { + await waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) + } + for waiter in releaseWaiters { + if let scopeError { + await waiter.fulfill(.failure(scopeError)) + } else { + await waiter.fulfill(.success(())) + } + } + for task in capabilityTasks { + await task.value + } + 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 @@ -87,6 +148,7 @@ package actor ConnectionCore { private var eventScopeActivationCancellationAction: (@Sendable () async -> Void)? private var inboundMessageQueue: TransportInboundMessageQueue private var closeAction: CloseAction + private var terminalTask: Task? private var state: State private var nextCloseWaiterID: UInt64 private var closeWaiters: [UInt64: CheckedContinuation] @@ -99,12 +161,17 @@ package actor ConnectionCore { responseTimeout: Duration? = .seconds(5), timeoutSleep: TimeoutSleep? = 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() @@ -125,6 +192,7 @@ package actor ConnectionCore { self.closeAction = closeAction ?? { await backend.detach() } + terminalTask = nil state = .open nextCloseWaiterID = 0 closeWaiters = [:] @@ -139,6 +207,8 @@ package actor ConnectionCore { // resources; native resources have their own isolated backstop. eventSubscribers.finishAndRemoveAll() eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) + terminalTask?.cancel() + terminalTask = nil let tasks = Array(capabilityTasks.values) capabilityTasks.removeAll() for task in tasks { @@ -155,10 +225,17 @@ package actor ConnectionCore { } private var isOpen: Bool { - if case .open = state { - return true + 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 false + return cause } package func events(for domain: ProtocolDomain) -> AsyncStream { @@ -386,16 +463,24 @@ package actor ConnectionCore { await terminate(.explicitClose) } - package func failFromNativeCallback(_ message: String) async { - await terminate(.fatal(message)) + @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 let .closed(cause): - return try terminalResult(for: cause).get() + case .closed: + return try terminalResult(for: claimedTerminalCause).get() } nextCloseWaiterID &+= 1 @@ -455,12 +540,7 @@ package actor ConnectionCore { } package var terminalCause: TerminalCause? { - switch state { - case .open: - nil - case let .closing(cause), let .closed(cause): - cause - } + terminalClaim.current } package func waitForCurrentMainPageTarget(timeout: Duration? = nil) async throws -> TransportSession.MainPageTarget { @@ -906,7 +986,7 @@ package actor ConnectionCore { } else { releaseWaiters = [] } - let terminalCause: TerminalCause? = if wireStateIsKnownInactive == false + let proposedTerminalCause: TerminalCause? = if wireStateIsKnownInactive == false || (activeLeaseFailed && Self.isCommandRejection(error)) { Self.terminalCauseForUncertainEnableFailure( error, @@ -916,11 +996,16 @@ package actor ConnectionCore { } else { nil } - if let terminalCause { + 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. - state = .closing(terminalCause) + let cause = terminalClaim.claim(proposedTerminalCause).cause + claimedTerminalCause = cause + state = .closing + } else { + claimedTerminalCause = nil } capabilities.states[key] = capability for waiter in activationWaiters { @@ -929,8 +1014,8 @@ package actor ConnectionCore { for waiter in releaseWaiters { await waiter.fulfill(.success(())) } - if let terminalCause { - await finishClaimedTermination(terminalCause) + if let claimedTerminalCause { + await finishClaimedTermination(claimedTerminalCause) } } @@ -990,13 +1075,14 @@ package actor ConnectionCore { return } - let terminalCause = Self.terminalCauseForUncertainDisableFailure( + 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. - state = .closing(terminalCause) + let claimedTerminalCause = terminalClaim.claim(proposedTerminalCause).cause + state = .closing let activationWaiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() let releaseWaiters = Array(capability.releaseWaiters.values) @@ -1008,7 +1094,7 @@ package actor ConnectionCore { for waiter in releaseWaiters { await waiter.fulfill(.failure(error)) } - await finishClaimedTermination(terminalCause) + await finishClaimedTermination(claimedTerminalCause) } default: @@ -1233,12 +1319,21 @@ package actor ConnectionCore { while isOpen, let rawMessage = inboundMessageQueue.popNext() { let parsed: ParsedProtocolMessage do { - parsed = try await TransportMessageParser.parse(rawMessage) + parsed = try await messageParser(rawMessage) } catch { - await terminate(.protocolViolation("Malformed root protocol message.")) + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Malformed root protocol message.")) + return + } + guard isOpen else { return } await handleRootMessage(parsed) + guard isOpen else { + return + } } } @@ -1285,6 +1380,9 @@ package actor ConnectionCore { } 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, @@ -1306,11 +1404,20 @@ package actor ConnectionCore { if method == "Target.dispatchMessageFromTarget" { guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData) else { - await terminate(.protocolViolation("Malformed Target.dispatchMessageFromTarget payload.")) + 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 { - await terminate(.protocolViolation("Malformed target protocol message.")) + guard isOpen else { return } await handleTargetMessage(targetMessage, targetID: dispatch.targetId) @@ -1331,7 +1438,13 @@ package actor ConnectionCore { paramsData: parsed.paramsData ) } catch { - await terminate(.protocolViolation("Failed to decode \(method): \(error)")) + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Failed to decode \(method): \(error)")) + return + } + guard isOpen else { return } await emit( @@ -1342,11 +1455,20 @@ package actor ConnectionCore { paramsData: parsed.paramsData, destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget ) + guard isOpen else { + return + } await emitResolvedStyleSheetAddedEvents(pendingStyleSheetAddedEvents) + 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) @@ -1366,9 +1488,21 @@ package actor ConnectionCore { } if method == "Target.dispatchMessageFromTarget" { - guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData), - let targetMessage = try? await TransportMessageParser.parse(dispatch.message) else { - await terminate(.protocolViolation("Malformed nested Target.dispatchMessageFromTarget payload.")) + 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) @@ -1514,6 +1648,9 @@ package actor ConnectionCore { from: previousMainPageTargetID, to: targetRegistry.currentMainPageTargetID ) + guard isOpen else { + return [] + } return resolvePendingStyleSheets(for: resolution) } @@ -1586,6 +1723,9 @@ package actor ConnectionCore { ) } + guard isOpen else { + return [] + } return resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) } @@ -1609,6 +1749,9 @@ package actor ConnectionCore { let keys = capabilities.states.keys.filter { $0.route == .currentPage } for key in keys { await reconcileCapability(for: key) + guard isOpen else { + return + } } return } @@ -1636,6 +1779,9 @@ package actor ConnectionCore { if newTargetID != nil { for key in keys { await reconcileCapability(for: key) + if !isOpen { + break + } } } for waiter in releaseWaiters { @@ -1699,6 +1845,9 @@ package actor ConnectionCore { let messages = provisionalTargetMessageStore.takeMessages(for: params.newTargetId) for message in messages { await handleTargetMessage(message, targetID: params.newTargetId) + guard isOpen else { + return + } } } @@ -1838,12 +1987,18 @@ package actor ConnectionCore { return } for event in events { + guard isOpen else { + return + } await emit( domain: .css, method: "CSS.styleSheetAdded", targetID: event.targetID, paramsData: event.paramsData ) + guard isOpen else { + return + } } } @@ -1867,6 +2022,9 @@ package actor ConnectionCore { paramsData: Data, destroyedCurrentMainPageTarget: Bool = false ) async { + guard isOpen else { + return + } let eventSequence = eventSequences.recordEvent(domain: domain) let envelope = ProtocolEvent( sequence: eventSequence.sequence, @@ -1952,7 +2110,7 @@ package actor ConnectionCore { terminalViolation = "Failed to decode \(method): \(error)" } if let terminalViolation { - await terminate(.protocolViolation(terminalViolation)) + handoffTermination(.protocolViolation(terminalViolation)) return } } @@ -2054,44 +2212,77 @@ package actor ConnectionCore { } private var terminalTransportError: TransportSession.Error { - switch state { - case .open, .closing(.explicitClose), .closed(.explicitClose): + switch claimedTerminalCause { + case .explicitClose: .transportClosed - case let .closing(.fatal(message)), let .closed(.fatal(message)): + case let .fatal(message): .transportFailure(message) - case let .closing(.protocolViolation(message)), let .closed(.protocolViolation(message)): + case let .protocolViolation(message): .transportFailure(message) } } - private var terminalScopeError: any Swift.Error { - switch state { - case .open, .closing(.explicitClose), .closed(.explicitClose): + private var terminalScopeError: WebInspectorProxyError { + switch claimedTerminalCause { + case .explicitClose: WebInspectorProxyError.closed - case let .closing(.fatal(message)), let .closed(.fatal(message)): + case let .fatal(message): WebInspectorProxyError.transportFailure(message) - case let .closing(.protocolViolation(message)), let .closed(.protocolViolation(message)): + case let .protocolViolation(message): WebInspectorProxyError.protocolViolation(message) } } - private func terminate(_ cause: TerminalCause) async { + private func terminate(_ proposedCause: TerminalCause) async { + let cause = terminalClaim.claim(proposedCause).cause switch state { case .open: - state = .closing(cause) + 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: (any Swift.Error)? = cause == .explicitClose ? nil : terminalScopeError + let scopeError: WebInspectorProxyError? = cause == .explicitClose ? nil : terminalScopeError if let scopeError { eventScopes.finishSubscribers(with: scopeError) } @@ -2120,12 +2311,8 @@ package actor ConnectionCore { for waiter in eventScopeWaiters { waiter.continuation.resume() } - for pending in replyStore.pendingReplies { - await pending.promise.fulfill(.failure(transportError)) - } - for waiter in mainPageTargetWaiterStore.removeAll() { - await waiter.fulfill(.failure(transportError)) - } + let pendingReplies = replyStore.pendingReplies.map(\.promise) + let mainPageTargetWaiters = mainPageTargetWaiterStore.removeAll() replyStore.removeAll() provisionalTargetMessageStore.removeAll() inboundMessageQueue = TransportInboundMessageQueue() @@ -2133,35 +2320,34 @@ package actor ConnectionCore { eventSubscribers.finishAndRemoveAll() } - for waiter in activationWaiters { - await waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) - } - for waiter in releaseWaiters { - if let scopeError { - await waiter.fulfill(.failure(scopeError)) - } else { - await waiter.fulfill(.success(())) - } - } - for task in runningCapabilityTasks { - await task.value - } - - let closeAction = self.closeAction - await closeAction() + return TerminalOperation( + transportError: transportError, + scopeError: scopeError, + pendingReplies: pendingReplies, + mainPageTargetWaiters: mainPageTargetWaiters, + activationWaiters: activationWaiters, + releaseWaiters: releaseWaiters, + capabilityTasks: runningCapabilityTasks, + 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 else { + guard case .closing = state, + claimedTerminalCause == cause else { preconditionFailure("ConnectionCore terminal state changed outside terminate(_:).") } - state = .closed(cause) + state = .closed if cause == .explicitClose { eventScopes.finishSubscribers(with: nil) eventSubscribers.finishAndRemoveAll() } resumeCloseWaiters(with: terminalResult(for: cause)) + terminalTask = nil } private func terminalResult(for cause: TerminalCause) -> Result { @@ -2179,8 +2365,8 @@ package actor ConnectionCore { id: UInt64, continuation: CheckedContinuation ) { - if case let .closed(cause) = state { - continuation.resume(with: terminalResult(for: cause)) + if case .closed = state { + continuation.resume(with: terminalResult(for: claimedTerminalCause)) return } guard cancelledCloseWaiterIDs.remove(id) == nil else { diff --git a/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift index 3a1e078e..937d81cf 100644 --- a/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift +++ b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift @@ -56,6 +56,46 @@ func nativeAttachmentExplicitCloseOrdersReceiverDetachRestoreAndWaiters() async #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 { @@ -223,7 +263,10 @@ private struct NativeAttachmentTestGraph { init( webView: WKWebView, recorder: NativeDetachRecorder, - gate: NativeDetachGate + gate: NativeDetachGate, + parser: @escaping ConnectionCore.MessageParser = { + try await TransportMessageParser.parse($0) + } ) { let receiver = TransportReceiver() let backend = FakeNativeAttachmentBackend( @@ -239,6 +282,7 @@ private struct NativeAttachmentTestGraph { ) let core = ConnectionCore( backend: backend, + messageParser: parser, closeAction: { await attachment.close() } @@ -400,6 +444,54 @@ private actor NativeCloseCompletionProbe { } } +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( diff --git a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift new file mode 100644 index 00000000..973ff3e0 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift @@ -0,0 +1,474 @@ +import Foundation +import Testing +import WebInspectorTestSupport +@testable import WebInspectorProxyKit + +@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 explicitCloseKeepsLegacyAndStructuredStreamsOpenUntilReceiverQuiesces() 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 legacyStream = await graph.core.events(for: .network) + let legacyCompletion = ReceiverCompletionProbe() + let structuredReady = ReceiverAsyncGate() + let structuredCompletion = ReceiverCompletionProbe() + + let legacyTask = Task { + var iterator = legacyStream.makeAsyncIterator() + let event = await iterator.next() + await legacyCompletion.finish() + return event + } + 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 legacyCompletion.isFinished == false) + #expect(await structuredCompletion.isFinished == false) + #expect(await graph.core.activeEventScopeSubscriberCountForTesting() == 1) + + await parser.release() + await closeTask.value + + #expect(await legacyTask.value == nil) + #expect(await structuredTask.value) + #expect(await legacyCompletion.isFinished) + #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/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index d58062db..d64ea46b 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -3095,7 +3095,8 @@ func structuredNetworkScopeThrowsTransportFailureOnNativeFatal() async throws { await bodyGate.waitUntilStarted() await bodyGate.release() - await transport.failFromNativeCallback("native fatal") + let fatalHandoff = try #require(transport.failFromNativeCallback("native fatal")) + await fatalHandoff.value #expect(await scopeTask.value == .transportFailure) } From 98fb90cbcb6d8be9e3a9c68f87ff22a8352ff07d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:17:10 +0900 Subject: [PATCH 12/81] refactor(proxy)!: replace domain clients with handles BREAKING CHANGE: DOM, CSS, Network, Console, Runtime, and Page are now target-scoped domain handles; nested Client types were removed. --- .../StoryA2ProxyKitContractTests.swift | 59 +++- Docs/WebInspectorKitsArchitecture.md | 34 +- Sources/WebInspectorProxyKit/CSS.swift | 239 ++++++------- Sources/WebInspectorProxyKit/Console.swift | 128 ++++--- Sources/WebInspectorProxyKit/DOM.swift | 332 +++++++++--------- Sources/WebInspectorProxyKit/Inspector.swift | 51 ++- Sources/WebInspectorProxyKit/Network.swift | 127 ++++--- Sources/WebInspectorProxyKit/Page.swift | 30 +- Sources/WebInspectorProxyKit/Runtime.swift | 206 ++++++----- .../StructuredEventScopes.swift | 40 +-- .../WebInspectorDomainHandle.swift | 135 +++++++ .../WebInspectorProxyKit.md | 2 +- .../WebInspectorTarget.swift | 134 ++----- .../DomainHandleTests.swift | 107 ++++++ 14 files changed, 905 insertions(+), 719 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift create mode 100644 Tests/WebInspectorProxyKitTests/DomainHandleTests.swift diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift index 9edf707a..e1ad4c74 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift @@ -13,8 +13,35 @@ func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() } #expect(await runtime.proxy.canReload) + let page = runtime.proxy.page + let targetDOM: DOM = target.dom + let targetCSS: CSS = target.css + let targetNetwork: Network = target.network + let targetConsole: Console = target.console + let targetRuntime: Runtime = target.runtime + let targetPage: Page = target.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 + _ = ( + targetDOM, + targetCSS, + targetConsole, + targetRuntime, + targetPage, + currentPageDOM, + currentPageCSS, + currentPageNetwork, + currentPageConsole, + currentPageRuntime, + currentPageCommands + ) + await runtime.backend.enqueue((), for: "Network", method: "enable") - try await target.network.enable() + try await targetNetwork.enable() let commands = await runtime.backend.recordedCommands() #expect(commands.contains(RecordedCommand(domain: "Network", method: "enable"))) @@ -73,3 +100,33 @@ func webInspectorProxyNetworkEventsMulticastToConsumerSubscribers() async throws #expect(firstTimestamp == 42) #expect(secondTimestamp == 42) } + +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/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 71d85829..aded2a29 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -352,7 +352,7 @@ semantic model state in DataKit and UI-specific state in WebInspectorUI. | Variation | Absorbed by | Must not leak into | | --- | --- | --- | -| live native bridge versus scripted backend | internal connection-core backend boundary | domain clients and DataKit models | +| live native bridge versus scripted backend | internal connection-core backend boundary | 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 | @@ -463,12 +463,12 @@ public struct WebInspectorPage: Sendable { public struct Generation: Hashable, Sendable { /* opaque */ } public var generation: Generation { get async throws } - public var dom: DOM.Client { get } - public var css: CSS.Client { get } - public var network: Network.Client { get } - public var console: Console.Client { get } - public var runtime: Runtime.Client { get } - public var page: Page.Client { get } + 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 { @@ -486,7 +486,7 @@ public struct WebInspectorScopeError: Error { public let cleanupError: any Error } -extension Network.Client { +extension Network { public func withEvents( buffering: WebInspectorEventBufferingPolicy = .bounded(256), isolation: isolated (any Actor)? = #isolation, @@ -500,7 +500,21 @@ extension Network.Client { } ``` -DOM, CSS, Console, and Runtime expose the same structured event scope. There is +`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. + +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 @@ -1472,7 +1486,7 @@ deinitializer is expected to break a cycle. | Surface | Access | Reason | | --- | --- | --- | -| Proxy, logical page, typed domain clients/DTOs, structured event scopes | `public` | direct ProxyKit consumer story | +| Proxy, logical page, typed domain handles/DTOs, structured event scopes | `public` | direct ProxyKit consumer story | | 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 | diff --git a/Sources/WebInspectorProxyKit/CSS.swift b/Sources/WebInspectorProxyKit/CSS.swift index 095ed03f..41d5ce09 100644 --- a/Sources/WebInspectorProxyKit/CSS.swift +++ b/Sources/WebInspectorProxyKit/CSS.swift @@ -1,129 +1,120 @@ 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() - ) - } - - /// 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 context.withEvents( - domain: .css, - buffering: buffering, - isolation: isolation, - extract: { event in - guard case let .css(value) = event else { - return nil - } - return value - }, - operation - ) - } - - /// 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 + } + + /// Enables CSS domain events and commands for the target. + public func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } + + /// Disables CSS domain events for the target. + public func disable() async throws { + try await dispatchVoid( + method: "disable", + payload: DisablePayload() + ) + } + + /// 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 + ) + } + + /// 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 + ) + } + + /// 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 + ) + } + + /// CSS domain events emitted by this target. + public var events: EventStream { + EventStream { + endpoint.cssEvents() } } diff --git a/Sources/WebInspectorProxyKit/Console.swift b/Sources/WebInspectorProxyKit/Console.swift index f0ef2c36..5d4b6859 100644 --- a/Sources/WebInspectorProxyKit/Console.swift +++ b/Sources/WebInspectorProxyKit/Console.swift @@ -1,78 +1,74 @@ 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() - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// 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 context.withEvents( - domain: .console, - buffering: buffering, - isolation: isolation, - extract: { event in - guard case let .console(value) = event else { - return nil - } - return value.event - }, - operation - ) + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .console(value) = event else { + return nil } + return value.event + } - /// Clears console messages in the inspected target. - public func clearMessages() async throws { - try await context.dispatchVoid( - domain: .console, - method: "clearMessages", - payload: ClearMessagesPayload() - ) - } + /// Enables Console domain events and commands for the target. + public func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } - /// 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) - ) - } + /// Disables Console domain events for the target. + public func disable() async throws { + try await dispatchVoid( + method: "disable", + payload: DisablePayload() + ) + } - /// 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 + ) + } + + /// Clears console messages in the inspected target. + public func clearMessages() async throws { + try await dispatchVoid( + method: "clearMessages", + payload: ClearMessagesPayload() + ) + } + + /// 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) + ) + } + + /// Console domain events emitted by this target. + public var events: EventStream { + EventStream { + endpoint.consoleEvents() } } diff --git a/Sources/WebInspectorProxyKit/DOM.swift b/Sources/WebInspectorProxyKit/DOM.swift index 953f3f7f..280003c5 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,195 +33,163 @@ 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 context.withEvents( - domain: .dom, - buffering: buffering, - isolation: isolation, - extract: { event in - guard case let .dom(value) = event else { - return nil - } - return value - }, - operation - ) - } + /// 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 into a DOM node identity. + 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) - ) - } + /// Enables or disables WebKit's element picker. + public 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() - ) - } + /// 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 context.dispatchVoid( - domain: .dom, - method: "redo", - payload: RedoPayload() - ) - } + /// Redoes the most recent DOM edit recorded by WebKit. + public func redo() async throws { + try await dispatchVoid( + method: "redo", + payload: RedoPayload() + ) + } - /// DOM domain events emitted by this target. - public var events: EventStream { - EventStream { - context.domEvents() - } + /// DOM domain events emitted by this target. + public var events: EventStream { + EventStream { + endpoint.domEvents() } } diff --git a/Sources/WebInspectorProxyKit/Inspector.swift b/Sources/WebInspectorProxyKit/Inspector.swift index 49675a6f..06a3a660 100644 --- a/Sources/WebInspectorProxyKit/Inspector.swift +++ b/Sources/WebInspectorProxyKit/Inspector.swift @@ -1,36 +1,33 @@ import Foundation -package enum Inspector { - package struct Client: Sendable { - private let context: DomainClientContext +package struct Inspector: Sendable, WebInspectorDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.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 func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } - package func initialized() async throws { - try await context.dispatchVoid( - domain: .inspector, - method: "initialized", - payload: InitializedPayload() - ) - } + 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 { diff --git a/Sources/WebInspectorProxyKit/Network.swift b/Sources/WebInspectorProxyKit/Network.swift index 3eaa5bb8..b3f5e50e 100644 --- a/Sources/WebInspectorProxyKit/Network.swift +++ b/Sources/WebInspectorProxyKit/Network.swift @@ -1,76 +1,73 @@ 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() - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// 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 context.withEvents( - domain: .network, - buffering: buffering, - isolation: isolation, - extract: { event in - guard case let .network(value) = event else { - return nil - } - return value - }, - operation - ) + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .network(value) = event else { + return nil } + return value + } - /// 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 - ) - } + /// Enables Network domain events and commands for the target. + public func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } - /// Network domain events emitted by this target. - public var events: EventStream { - EventStream { - context.networkEvents() - } + /// Disables Network domain events for the target. + public func disable() async throws { + try await dispatchVoid( + method: "disable", + payload: DisablePayload() + ) + } + + /// 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 + ) + } + + /// 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 + ) + } + + /// Network domain events emitted by this target. + public var events: EventStream { + EventStream { + endpoint.networkEvents() } } 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/Runtime.swift b/Sources/WebInspectorProxyKit/Runtime.swift index 41d62bc5..25e2510f 100644 --- a/Sources/WebInspectorProxyKit/Runtime.swift +++ b/Sources/WebInspectorProxyKit/Runtime.swift @@ -1,124 +1,116 @@ 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 - } + package let endpoint: DomainEndpoint - /// 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 init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// Disables Runtime domain events for the target. - public func disable() async throws { - try await context.dispatchVoid( - domain: .runtime, - method: "disable", - payload: DisablePayload() - ) + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .runtime(value) = event else { + return nil } + return value + } - /// 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 context.withEvents( - domain: .runtime, - buffering: buffering, - isolation: isolation, - extract: { event in - guard case let .runtime(value) = event else { - return nil - } - return value - }, - operation - ) - } + /// Enables Runtime domain events and commands for the target. + public func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } - /// 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 - ) - } + /// Disables Runtime domain events for the target. + public func disable() async throws { + try await dispatchVoid( + method: "disable", + payload: DisablePayload() + ) + } - /// 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 - ) - } + /// 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 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 - ) - } + /// Evaluates a JavaScript expression in an execution context. + public func evaluate( + _ expression: String, + in context: ExecutionContext.ID? = nil + ) async throws -> EvaluationResult { + try await dispatch( + method: "evaluate", + payload: EvaluatePayload(expression: expression, context: context), + returning: EvaluationResult.self + ) + } - /// 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 - ) - } + /// 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 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 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 + ) + } - /// 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 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 + ) + } - /// Runtime domain events emitted by this target. - public var events: EventStream { - EventStream { - context.runtimeEvents() - } + /// Releases one remote object handle. + public func releaseObject(_ id: RemoteObject.ID) async throws { + try await dispatchVoid( + method: "releaseObject", + payload: ReleaseObjectPayload(id: id) + ) + } + + /// 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) + ) + } + + /// Runtime domain events emitted by this target. + public var events: EventStream { + EventStream { + endpoint.runtimeEvents() } } diff --git a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift index cf0358ee..d761ed56 100644 --- a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift +++ b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift @@ -259,38 +259,38 @@ public struct WebInspectorPage: Sendable { } } - /// A typed client for DOM protocol commands and events. - public var dom: DOM.Client { - DOM.Client(context: context) + /// A target-scoped handle for DOM protocol commands and events. + public var dom: DOM { + DOM(endpoint: endpoint) } - /// A typed client for CSS protocol commands and events. - public var css: CSS.Client { - CSS.Client(context: context) + /// A target-scoped handle for CSS protocol commands and events. + public var css: CSS { + CSS(endpoint: endpoint) } - /// A typed client for Network protocol commands and events. - public var network: Network.Client { - Network.Client(context: context) + /// A target-scoped handle for Network protocol commands and events. + public var network: Network { + Network(endpoint: endpoint) } - /// A typed client for Console protocol commands and events. - public var console: Console.Client { - Console.Client(context: context) + /// A target-scoped handle for Console protocol commands and events. + public var console: Console { + Console(endpoint: endpoint) } - /// A typed client for Runtime protocol commands and events. - public var runtime: Runtime.Client { - Runtime.Client(context: context) + /// A target-scoped handle for Runtime protocol commands and events. + public var runtime: Runtime { + Runtime(endpoint: endpoint) } - /// A typed client for Page protocol commands. - public var page: Page.Client { - Page.Client(context: context) + /// A target-scoped handle for Page protocol commands. + public var page: Page { + Page(endpoint: endpoint) } - private var context: DomainClientContext { - DomainClientContext(proxy: proxy, targetID: .currentPage, route: .currentPage) + private var endpoint: DomainEndpoint { + DomainEndpoint(proxy: proxy, targetID: .currentPage, route: .currentPage) } } diff --git a/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift new file mode 100644 index 00000000..bdae676c --- /dev/null +++ b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift @@ -0,0 +1,135 @@ +import Foundation + +package struct DomainEndpoint: 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 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 { + guard let backend = proxy.structuredEventBackend else { + throw unimplementedCommand(domain: domain.rawValue, method: "withEvents") + } + return try await withWebInspectorEventScope( + backend: backend, + targetID: targetID, + route: route, + domain: domain, + buffering: buffering, + isolation: isolation, + extract: extract, + operation + ) + } + + 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) + } +} + +/// 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/WebInspectorProxyKit.docc/WebInspectorProxyKit.md b/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md index ab47044e..4a93c06c 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md @@ -6,7 +6,7 @@ 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 page target, and exposes typed domain handles from ``WebInspectorTarget``. Create a ``WebInspectorProxy``, wait for the current page, and send protocol commands through target-scoped clients: diff --git a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index cc542464..c026f751 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -39,7 +39,7 @@ package struct RoutingTargetID: Hashable, Sendable { /// A typed handle for a Web Inspector protocol target. /// -/// Targets vend domain clients such as ``dom``, ``network``, and ``runtime``. +/// Targets vend domain handles 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 { @@ -69,7 +69,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { case serviceWorker } - /// The target identity used by typed domain clients. + /// The target identity used by typed domain handles. public let id: ID /// The backend target kind. @@ -116,38 +116,38 @@ public struct WebInspectorTarget: Identifiable, Sendable { ) } - /// 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 target-scoped handle for DOM protocol commands and events. + public var dom: DOM { + DOM(endpoint: endpoint) } - /// 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 target-scoped handle for CSS protocol commands and events. + public var css: CSS { + CSS(endpoint: endpoint) } - /// A typed client for Network protocol commands and events. - public var network: Network.Client { - Network.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + /// A target-scoped handle for Network protocol commands and events. + public var network: Network { + Network(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)) + /// A target-scoped handle for Console protocol commands and events. + public var console: Console { + Console(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)) + /// A target-scoped handle for Runtime protocol commands and events. + public var runtime: Runtime { + Runtime(endpoint: endpoint) } - /// A typed client for Page protocol commands. - public var page: Page.Client { - Page.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + /// A target-scoped handle for Page protocol commands. + public var page: Page { + Page(endpoint: endpoint) } - package var inspector: Inspector.Client { - Inspector.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var inspector: Inspector { + Inspector(endpoint: endpoint) } package var lifecycleEvents: AsyncStream { @@ -163,6 +163,10 @@ public struct WebInspectorTarget: Identifiable, Sendable { await proxy.waitForEventSubscription(targetID: id, route: route, domain: domain) } } + + private var endpoint: DomainEndpoint { + DomainEndpoint(proxy: proxy, targetID: id, route: route) + } } package extension WebInspectorProxy { @@ -177,89 +181,3 @@ package extension WebInspectorProxy { ) } } - -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 - ) - } - - 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 { - guard let backend = proxy.structuredEventBackend else { - throw unimplementedCommand(domain: domain.rawValue, method: "withEvents") - } - return try await withWebInspectorEventScope( - backend: backend, - targetID: targetID, - route: route, - domain: domain, - buffering: buffering, - isolation: isolation, - extract: extract, - operation - ) - } - - 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/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) +} From 1e2ad997cfb36db6ea9aedf066b7e264eee8cf24 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:37:15 +0900 Subject: [PATCH 13/81] refactor(proxy): classify internal reply ownership --- .../Transport/TransportReplyStore.swift | 131 ++++++++++++-- .../Transport/TransportSession.swift | 169 +++++++++++++----- .../TransportReplyStoreTests.swift | 76 +++++++- ...torProxyTransportCommandBackendTests.swift | 96 +++++++++- 4 files changed, 403 insertions(+), 69 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index d18f63d3..72a5300e 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -2,14 +2,76 @@ 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 client + case capability( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + 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 client( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise + ) -> PendingReply { + PendingReply( + purpose: .client, + domain: domain, + method: method, + 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 + ) + } } - enum PendingKey: Sendable { + enum PendingKey: Hashable, Sendable { case root(UInt64) case target(ReplyKey) } @@ -35,10 +97,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 +126,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 +146,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 +165,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) } } @@ -140,8 +225,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 ee38f9a3..be157e22 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1,11 +1,16 @@ import Foundation import Synchronization +private struct ConnectionPendingReplyOwnership: Equatable, Sendable { + let key: TransportSession.PendingKey + let purpose: TransportSession.PendingReply.Purpose +} + private struct ConnectionCapabilityCommandOperation: Sendable { let backend: any TransportBackend let message: String let promise: ReplyPromise - let pendingKey: TransportSession.PendingKey + let pendingReplyOwnership: ConnectionPendingReplyOwnership let timeoutAction: (@Sendable () async -> Void)? func value() async throws { @@ -33,7 +38,7 @@ private struct ConnectionCapabilityCommandOperation: Sendable { private struct ConnectionCapabilityTask: Sendable { let task: Task - let pendingKey: TransportSession.PendingKey? + let pendingReplyOwnership: ConnectionPendingReplyOwnership? } private struct PhysicalTargetDisappearanceWaiters: Sendable { @@ -90,7 +95,7 @@ package actor ConnectionCore { private struct TerminalOperation: Sendable { let transportError: TransportSession.Error let scopeError: WebInspectorProxyError? - let pendingReplies: [ReplyPromise] + let pendingReplies: [TransportSession.PendingReply] let mainPageTargetWaiters: [ReplyPromise] let activationWaiters: [ReplyPromise] let releaseWaiters: [ReplyPromise] @@ -99,7 +104,7 @@ package actor ConnectionCore { func run() async { for pending in pendingReplies { - await pending.fulfill(.failure(transportError)) + await pending.promise.fulfill(.failure(transportError)) } for waiter in mainPageTargetWaiters { await waiter.fulfill(.failure(transportError)) @@ -213,8 +218,12 @@ package actor ConnectionCore { capabilityTasks.removeAll() for task in tasks { task.task.cancel() - if let pendingKey = task.pendingKey { - replyStore.removePendingReply(pendingKey) + 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." + ) } } precondition(replyStore.pendingReplies.isEmpty, "ConnectionCore deinitialized with pending replies; call close() explicitly.") @@ -596,6 +605,12 @@ package actor ConnectionCore { ) } + func pendingReplyPurposes() -> [ + TransportSession.PendingKey: TransportSession.PendingReply.Purpose + ] { + replyStore.pendingReplyPurposes + } + package func targetID(forExecutionContext key: RuntimeContext.Key) -> ProtocolTarget.ID? { runtimeContextRegistry.targetID(for: key) } @@ -809,7 +824,12 @@ package actor ConnectionCore { ) { let operation: ConnectionCapabilityCommandOperation do { - operation = try makeCapabilityCommandOperation(action, for: key) + operation = try makeCapabilityCommandOperation( + action, + for: key, + generation: generation, + operationID: id + ) } catch { let result: Result = .failure( Self.mapCapabilityError(error, action: action, domain: key.domain) @@ -823,7 +843,11 @@ package actor ConnectionCore { result: result ) } - capabilityTasks[id] = ConnectionCapabilityTask(task: task, pendingKey: nil) + precondition(capabilityTasks[id] == nil, "A capability operation identifier already has an owner.") + capabilityTasks[id] = ConnectionCapabilityTask( + task: task, + pendingReplyOwnership: nil + ) return } @@ -843,15 +867,18 @@ package actor ConnectionCore { result: result ) } + precondition(capabilityTasks[id] == nil, "A capability operation identifier already has an owner.") capabilityTasks[id] = ConnectionCapabilityTask( task: task, - pendingKey: operation.pendingKey + pendingReplyOwnership: operation.pendingReplyOwnership ) } private func makeCapabilityCommandOperation( _ action: CapabilityWireAction, - for key: ConnectionCapabilityKey + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 ) throws -> ConnectionCapabilityCommandOperation { let method: String switch action { @@ -890,17 +917,24 @@ package actor ConnectionCore { message: message ) let promise = ReplyPromise() + let pendingReply = TransportSession.PendingReply.capability( + domain: domain, + method: method, + targetID: targetID, + promise: promise, + key: key, + generation: generation, + operationID: operationID + ) replyStore.insertTargetReply( - TransportSession.PendingReply( - domain: domain, - method: method, - targetID: targetID, - promise: promise, - hasBufferedProvisionalResponse: false - ), + pendingReply, key: replyKey, rootWrapperID: outerCommandID ) + let pendingReplyOwnership = ConnectionPendingReplyOwnership( + key: pendingKey, + purpose: pendingReply.purpose + ) let timeoutAction: (@Sendable () async -> Void)? if let responseTimeout { @@ -929,7 +963,7 @@ package actor ConnectionCore { backend: backend, message: wrapperMessage, promise: promise, - pendingKey: pendingKey, + pendingReplyOwnership: pendingReplyOwnership, timeoutAction: timeoutAction ) } @@ -941,8 +975,12 @@ package actor ConnectionCore { action: CapabilityWireAction, result: Result ) async { - if let pendingKey = capabilityTasks.removeValue(forKey: id)?.pendingKey { - replyStore.removePendingReply(pendingKey) + 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 @@ -1220,12 +1258,11 @@ package actor ConnectionCore { private func sendRoot(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { let commandID = allocateCommandID() let promise = ReplyPromise() - replyStore.insertRootReply(TransportSession.PendingReply( + replyStore.insertRootReply(TransportSession.PendingReply.client( domain: command.domain, method: command.method, targetID: nil, - promise: promise, - hasBufferedProvisionalResponse: false + promise: promise ), commandID: commandID) do { try Task.checkCancellation() @@ -1256,12 +1293,11 @@ package actor ConnectionCore { let outerCommandID = allocateCommandID() let key = TransportSession.ReplyKey(targetID: targetID, commandID: innerCommandID) let promise = ReplyPromise() - replyStore.insertTargetReply(TransportSession.PendingReply( + replyStore.insertTargetReply(TransportSession.PendingReply.client( domain: command.domain, method: command.method, targetID: targetID, - promise: promise, - hasBufferedProvisionalResponse: false + promise: promise ), key: key, rootWrapperID: outerCommandID) do { try Task.checkCancellation() @@ -1387,14 +1423,14 @@ package actor ConnectionCore { let key = replyStore.takeTargetReplyKey(forRootWrapperID: id) { if parsed.errorMessage != nil, let pending = replyStore.removeTargetReply(for: key) { - await resolve(pending, parsed: parsed) + await resolve(pending, key: .target(key), parsed: parsed) } return } if let id = parsed.id, let pending = replyStore.removeRootReply(commandID: id) { - await resolve(pending, parsed: parsed) + await resolve(pending, key: .root(id), parsed: parsed) return } @@ -1478,7 +1514,7 @@ package actor ConnectionCore { 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) + await resolve(pending, key: .target(key), parsed: parsed) return } } @@ -1529,7 +1565,12 @@ package actor ConnectionCore { ) } - private func resolve(_ pending: TransportSession.PendingReply, parsed: ParsedProtocolMessage) async { + private func resolve( + _ pending: TransportSession.PendingReply, + key: TransportSession.PendingKey, + parsed: ParsedProtocolMessage + ) async { + validateReplyOwnership(pending, key: key) if let errorMessage = parsed.errorMessage { await pending.promise.fulfill( .failure( @@ -1543,20 +1584,62 @@ package actor ConnectionCore { return } let eventSequence = eventSequences.current + let result = ProtocolCommand.Result( + domain: pending.domain, + method: pending.method, + targetID: pending.targetID, + receivedSequence: eventSequence.sequence, + receivedDomainSequences: eventSequence.receivedDomainSequences, + resultData: parsed.resultData + ) + processSuccessfulReply(result, for: pending) 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 - ) - ) + .success(result) ) } + private func validateReplyOwnership( + _ pending: TransportSession.PendingReply, + key: TransportSession.PendingKey + ) { + switch pending.purpose { + case .client: + break + case let .capability(_, _, 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." + ) + } + } + + /// Synchronous phase boundary for successful reply-side effects. + /// + /// The ordered model feed adds capability markers to this purpose switch + /// in the next change. It must remain in the actor's inbound processing + /// slot before `ReplyPromise.fulfill` resumes the waiting operation. + private func processSuccessfulReply( + _ result: ProtocolCommand.Result, + for pending: TransportSession.PendingReply + ) { + switch pending.purpose { + case .client: + // Client replies have no internal publication side effect. + break + case .capability: + // The next change publishes the ordered model-feed marker here. + break + } + _ = result + } + private func updateRegistryFromRootEvent( method: String, targetID: ProtocolTarget.ID?, @@ -2301,6 +2384,11 @@ package actor ConnectionCore { capabilities.states[key] = capability } + let pendingReplyRecords = replyStore.pendingReplyRecords + for (key, pending) in pendingReplyRecords { + validateReplyOwnership(pending, key: key) + } + let pendingReplies = Array(pendingReplyRecords.values) let runningCapabilityTasks = capabilityTasks.values.map(\.task) capabilityTasks.removeAll() for task in runningCapabilityTasks { @@ -2311,7 +2399,6 @@ package actor ConnectionCore { for waiter in eventScopeWaiters { waiter.continuation.resume() } - let pendingReplies = replyStore.pendingReplies.map(\.promise) let mainPageTargetWaiters = mainPageTargetWaiterStore.removeAll() replyStore.removeAll() provisionalTargetMessageStore.removeAll() diff --git a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift index 047dc883..3912b604 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift @@ -6,15 +6,17 @@ import Testing func replyStoreRemovingTargetCleansWrapperIndex() { var store = TransportReplyStore() let oldKey = TransportSession.ReplyKey(targetID: .init("frame-old"), commandID: 7) + let pending = capabilityPendingReply(targetID: .init("frame-old"), operationID: 11) store.insertTargetReply( - pendingReply(targetID: .init("frame-old")), + pending, key: oldKey, rootWrapperID: 100 ) let removed = store.removeTargetReplies(for: .init("frame-old")) #expect(removed.count == 1) + #expect(removed.first?.purpose == pending.purpose) #expect(store.pendingTargetReplyKeys.isEmpty) #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) } @@ -23,47 +25,105 @@ func replyStoreRemovingTargetCleansWrapperIndex() { 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 ) 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 replyStoreReplacingCommandCleansStaleIndexes() { +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 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")), + clientPendingReply(targetID: .init("frame-old")), key: oldKey, rootWrapperID: 100 ) + let removed = store.removeTargetReply(for: oldKey) store.insertTargetReply( - pendingReply(targetID: .init("frame-new")), + clientPendingReply(targetID: .init("frame-new")), key: newKey, rootWrapperID: 200 ) + #expect(removed?.purpose == .client) #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 replyStoreRootRemovalPreservesClientPurpose() { + var store = TransportReplyStore() + store.insertRootReply( + clientPendingReply(targetID: nil), + commandID: 42 + ) + + let removed = store.removePendingReply(.root(42)) + + #expect(removed?.purpose == .client) + #expect(store.pendingRootReplyIDs.isEmpty) +} + +private func clientPendingReply( + targetID: ProtocolTarget.ID? +) -> TransportSession.PendingReply { + TransportSession.PendingReply.client( domain: .dom, method: "DOM.getDocument", targetID: targetID, + promise: ReplyPromise() + ) +} + +private func capabilityPendingReply( + targetID: ProtocolTarget.ID, + operationID: UInt64 +) -> TransportSession.PendingReply { + TransportSession.PendingReply.capability( + domain: .network, + method: "Network.enable", + targetID: targetID, promise: ReplyPromise(), - hasBufferedProvisionalResponse: false + 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/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index d64ea46b..6a61b77c 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) @@ -2237,6 +2237,72 @@ func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries( #expect(entries[1].value.value == .number(42)) } +@Test +func pendingRepliesClassifyClientAndCapabilityOwners() 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 clientTask = Task { + try await proxy.page.page.reload() + } + let reload = try await waitForTargetMessage(backend, method: "Page.reload") + let reloadPendingKey = TransportSession.PendingKey.target( + TransportSession.ReplyKey( + targetID: reload.targetIdentifier, + commandID: try messageID(reload.message) + ) + ) + #expect(await transport.pendingReplyPurposes() == [reloadPendingKey: .client]) + await receiveTargetReply( + transport, + targetID: reload.targetIdentifier, + messageID: try messageID(reload.message), + result: "{}" + ) + try await clientTask.value + + let bodyGate = CloseConnectionGate() + let scopeTask = Task { + try await proxy.page.network.withEvents { _ in + await bodyGate.waitUntilReleased() + } + } + 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) + + 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) +} + @Test func structuredNetworkScopeBuffersReplayBeforeEnableReplyAndBalancesDisable() async throws { let backend = FakeTransportBackend() @@ -4091,6 +4157,34 @@ 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 .client: + 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"), From 75f30e7f29f880e349594621e18d0466ce991f0a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:43:19 +0900 Subject: [PATCH 14/81] refactor(testing): remove unchecked recorded value erasure --- .../AnySendableValue.swift | 13 +++++ .../WebInspectorTestBackend.swift | 51 +++++++------------ .../AnySendableValueTests.swift | 41 +++++++++++++++ 3 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/AnySendableValue.swift create mode 100644 Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift diff --git a/Sources/WebInspectorProxyKit/AnySendableValue.swift b/Sources/WebInspectorProxyKit/AnySendableValue.swift new file mode 100644 index 00000000..c083b003 --- /dev/null +++ b/Sources/WebInspectorProxyKit/AnySendableValue.swift @@ -0,0 +1,13 @@ +package struct AnySendableValue: Sendable { + package let value: any Sendable + + package init(_ value: Value) { + self.value = value + } + + package func cast( + as type: Value.Type = Value.self + ) -> Value? { + value as? Value + } +} diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift index 31c6391f..014ffe7d 100644 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift @@ -1,18 +1,6 @@ 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. @@ -24,7 +12,7 @@ public struct RecordedCommand: Equatable, Sendable { /// The protocol method for the command. public let method: String package let route: RoutingTargetID - package let payload: AnyRecordedValue + package let payload: AnySendableValue /// Creates an unscoped recorded command used for equality assertions. public init(domain: String, method: String) { @@ -32,7 +20,7 @@ public struct RecordedCommand: Equatable, Sendable { route = RoutingTargetID("unscoped-recorded-command") self.domain = domain self.method = method - payload = AnyRecordedValue(()) + payload = AnySendableValue(()) } package init( @@ -42,7 +30,7 @@ public struct RecordedCommand: Equatable, Sendable { route = command.route domain = command.domain.rawValue method = command.method - payload = AnyRecordedValue(command.payload) + payload = AnySendableValue(command.payload) } /// Compares recorded commands by domain and method. @@ -62,16 +50,16 @@ private struct CommandKey: Hashable, Sendable { var method: String } -private struct QueuedReply: @unchecked Sendable { - enum Storage { - case result(Any) - case failure(any Error) +private struct QueuedReply: Sendable { + enum Storage: Sendable { + case result(AnySendableValue) + case failure(any Error & Sendable) } var storage: Storage init(_ value: some Sendable) { - storage = .result(value) + storage = .result(AnySendableValue(value)) } init(failure error: any Error & Sendable) { @@ -533,23 +521,20 @@ extension WebInspectorTestBackend: WebInspectorProxyBackend { let queued = results.removeFirst() enqueuedReplies[key] = results.isEmpty ? nil : results - let value: Any switch queued.storage { - case let .result(result): - value = result + case let .result(erasedResult): + guard let result = erasedResult.cast(as: Result.self) else { + throw WebInspectorProxyError.commandFailed( + domain: command.domain.rawValue, + method: command.method, + message: "Enqueued result for \(command.domain.rawValue).\(command.method) has type " + + "\(type(of: erasedResult.value)); expected \(Result.self)." + ) + } + return 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( diff --git a/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift b/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift new file mode 100644 index 00000000..b9e34923 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift @@ -0,0 +1,41 @@ +import Testing +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +private actor ReferenceValue {} + +@Test +func anySendableValuePreservesReferenceIdentity() throws { + let value = ReferenceValue() + let erased = AnySendableValue(value) + + let restored = try #require(erased.cast(as: ReferenceValue.self)) + #expect(restored === value) +} + +@Test +func anySendableValueReturnsNilForTheWrongType() { + let erased = AnySendableValue(42) + + #expect(erased.cast(as: String.self) == nil) +} + +@Test +func recordedCommandPreservesItsTypedPayload() throws { + let nodeID = DOM.Node.ID("parent") + let payload = DOM.RequestChildNodesPayload(id: nodeID, depth: 2) + let command = WebInspectorProxyCommand( + targetID: WebInspectorTarget.ID("page"), + route: RoutingTargetID("page"), + domain: .dom, + method: "requestChildNodes", + payload: payload + ) + + let recorded = RecordedCommand(command: command) + let restored = try #require( + recorded.payload.cast(as: DOM.RequestChildNodesPayload.self) + ) + #expect(restored.id == nodeID) + #expect(restored.depth == 2) +} From ac033cc4d153bd69f69552e35d8a500605a219c4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:51:46 +0900 Subject: [PATCH 15/81] fix(proxy): await initial target discovery --- .../NativeInspectablePage.swift | 27 ++++ .../Transport/TransportReceiver.swift | 137 +++++++++++++++--- .../TransportReceiverLifecycleTests.swift | 91 ++++++++++++ 3 files changed, 237 insertions(+), 18 deletions(-) diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift index 24114fad..32f01d56 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift @@ -1,3 +1,4 @@ +import Dispatch import WebKit import WebInspectorNativeBridge @@ -58,6 +59,10 @@ package enum NativeConnectionCoreFactory { receiver.setCore(createdCore) try backend.attach() + try await awaitInitialTargetDiscovery( + receiver: receiver, + core: createdCore + ) return createdCore } catch { @@ -72,6 +77,28 @@ package enum NativeConnectionCoreFactory { 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 diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 0929f939..37737ba4 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -9,18 +9,31 @@ package final class TransportReceiver: 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 State: Sendable { var core = WeakCore() - var messages: [String] = [] + 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 isClosed = false var closeWaiters: [CheckedContinuation] = [] } private enum DrainStep: Sendable { - case deliver(core: ConnectionCore, message: String) + case deliver(core: ConnectionCore, message: QueuedMessage) case stop(closeWaiters: [CheckedContinuation]) } @@ -54,7 +67,9 @@ package final class TransportReceiver: Sendable { guard !$0.isClosed else { return nil as UInt64? } - $0.messages.append(message) + precondition($0.tailOrdinal < UInt64.max, "TransportReceiver exhausted its message ordinal space.") + $0.tailOrdinal += 1 + $0.messages.append(QueuedMessage(ordinal: $0.tailOrdinal, payload: message)) guard $0.core.value != nil else { return nil } @@ -73,17 +88,55 @@ package final class TransportReceiver: Sendable { } } + /// 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 } + } + + /// 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 shouldResume = 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 true + } + state.drainWaiters.append( + DrainWaiter(through: ordinal, continuation: continuation) + ) + return false + } + if shouldResume { + continuation.resume() + } + } + } + package func close() async { await withCheckedContinuation { (continuation: CheckedContinuation) in let readyWaiters = state.withLock { state in - Self.seal(&state) + let drainWaiters = Self.seal(&state) guard state.isDraining else { - return [continuation] + return ( + close: [continuation], + drain: drainWaiters + ) } state.closeWaiters.append(continuation) - return [] + return ( + close: [] as [CheckedContinuation], + drain: drainWaiters + ) } - Self.resume(readyWaiters) + Self.resume(readyWaiters.close) + Self.resumeDrainWaiters(readyWaiters.drain) } } @@ -93,10 +146,14 @@ package final class TransportReceiver: Sendable { /// active drain before detaching the native frontend. package func closeSynchronously() { let readyWaiters = state.withLock { state in - Self.seal(&state) - return Self.takeCloseWaitersIfQuiescent(from: &state) + let drainWaiters = Self.seal(&state) + return ( + close: Self.takeCloseWaitersIfQuiescent(from: &state), + drain: drainWaiters + ) } - Self.resume(readyWaiters) + Self.resume(readyWaiters.close) + Self.resumeDrainWaiters(readyWaiters.drain) } package func closeWaiterCountForTesting() -> Int { @@ -107,16 +164,22 @@ package final class TransportReceiver: Sendable { package func fail(_ message: String) -> Task? { let result: ( core: ConnectionCore?, - readyWaiters: [CheckedContinuation] + readyCloseWaiters: [CheckedContinuation], + readyDrainWaiters: [DrainWaiter] ) = state.withLock { state in guard !state.isClosed else { - return (nil, []) + return (nil, [], []) } let core = state.core.value - Self.seal(&state) - return (core, Self.takeCloseWaitersIfQuiescent(from: &state)) + let drainWaiters = Self.seal(&state) + return ( + core, + Self.takeCloseWaitersIfQuiescent(from: &state), + drainWaiters + ) } - Self.resume(result.readyWaiters) + Self.resume(result.readyCloseWaiters) + Self.resumeDrainWaiters(result.readyDrainWaiters) guard let core = result.core else { return nil } @@ -127,7 +190,8 @@ package final class TransportReceiver: Sendable { while true { switch nextDrainStep(generation: generation) { case let .deliver(core, message): - await core.receiveRootMessage(message) + await core.receiveRootMessage(message.payload) + complete(message.ordinal) case let .stop(closeWaiters): Self.resume(closeWaiters) return @@ -159,15 +223,46 @@ package final class TransportReceiver: Sendable { } } - private static func seal(_ state: inout State) { + 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) -> [DrainWaiter] { guard !state.isClosed else { - return + return [] } 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) + return drainWaiters + } + + 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( @@ -187,6 +282,12 @@ package final class TransportReceiver: Sendable { } } + private static func resumeDrainWaiters(_ waiters: [DrainWaiter]) { + for waiter in waiters { + waiter.continuation.resume() + } + } + private func compactMessagesIfNeeded(in state: inout State) { if state.messageStartIndex == state.messages.count { state.messages.removeAll(keepingCapacity: true) diff --git a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift index 973ff3e0..e5ad51b1 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift @@ -1,8 +1,99 @@ +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() + #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() + 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) From 74a1b514212f546f84df693cd214ca692200d708 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:08:03 +0900 Subject: [PATCH 16/81] test(proxy): lock initial discovery drain barrier --- .../Transport/TransportReceiver.swift | 78 ++++++++++++++----- .../TransportReceiverLifecycleTests.swift | 2 + 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 37737ba4..80145ce3 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -19,6 +19,11 @@ package final class TransportReceiver: Sendable { let continuation: CheckedContinuation } + private struct SealedWaiters: Sendable { + let drain: [DrainWaiter] + let drainRegistration: [CheckedContinuation] + } + private struct State: Sendable { var core = WeakCore() var messages: [QueuedMessage] = [] @@ -28,6 +33,7 @@ package final class TransportReceiver: Sendable { var tailOrdinal: UInt64 = 0 var completedOrdinal: UInt64 = 0 var drainWaiters: [DrainWaiter] = [] + var drainWaiterRegistrationWaiters: [CheckedContinuation] = [] var isClosed = false var closeWaiters: [CheckedContinuation] = [] } @@ -100,43 +106,52 @@ package final class TransportReceiver: Sendable { /// through `ordinal`, or until the receiver closes. package func waitUntilDrained(through ordinal: UInt64) async { await withCheckedContinuation { continuation in - let shouldResume = state.withLock { state 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 true + return ( + shouldResume: true, + registrationWaiters: [] as [CheckedContinuation] + ) } state.drainWaiters.append( DrainWaiter(through: ordinal, continuation: continuation) ) - return false + let registrationWaiters = state.drainWaiterRegistrationWaiters + state.drainWaiterRegistrationWaiters.removeAll(keepingCapacity: false) + return ( + shouldResume: false, + registrationWaiters: registrationWaiters + ) } - if shouldResume { + if result.shouldResume { continuation.resume() } + Self.resume(result.registrationWaiters) } } package func close() async { await withCheckedContinuation { (continuation: CheckedContinuation) in let readyWaiters = state.withLock { state in - let drainWaiters = Self.seal(&state) + let sealedWaiters = Self.seal(&state) guard state.isDraining else { return ( close: [continuation], - drain: drainWaiters + sealed: sealedWaiters ) } state.closeWaiters.append(continuation) return ( close: [] as [CheckedContinuation], - drain: drainWaiters + sealed: sealedWaiters ) } Self.resume(readyWaiters.close) - Self.resumeDrainWaiters(readyWaiters.drain) + Self.resumeSealedWaiters(readyWaiters.sealed) } } @@ -146,40 +161,55 @@ package final class TransportReceiver: Sendable { /// active drain before detaching the native frontend. package func closeSynchronously() { let readyWaiters = state.withLock { state in - let drainWaiters = Self.seal(&state) + let sealedWaiters = Self.seal(&state) return ( close: Self.takeCloseWaitersIfQuiescent(from: &state), - drain: drainWaiters + sealed: sealedWaiters ) } Self.resume(readyWaiters.close) - Self.resumeDrainWaiters(readyWaiters.drain) + 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], - readyDrainWaiters: [DrainWaiter] + sealedWaiters: SealedWaiters ) = state.withLock { state in guard !state.isClosed else { - return (nil, [], []) + return (nil, [], SealedWaiters(drain: [], drainRegistration: [])) } let core = state.core.value - let drainWaiters = Self.seal(&state) + let sealedWaiters = Self.seal(&state) return ( core, Self.takeCloseWaitersIfQuiescent(from: &state), - drainWaiters + sealedWaiters ) } Self.resume(result.readyCloseWaiters) - Self.resumeDrainWaiters(result.readyDrainWaiters) + Self.resumeSealedWaiters(result.sealedWaiters) guard let core = result.core else { return nil } @@ -235,9 +265,9 @@ package final class TransportReceiver: Sendable { Self.resumeDrainWaiters(readyWaiters) } - private static func seal(_ state: inout State) -> [DrainWaiter] { + private static func seal(_ state: inout State) -> SealedWaiters { guard !state.isClosed else { - return [] + return SealedWaiters(drain: [], drainRegistration: []) } state.isClosed = true state.generation &+= 1 @@ -246,7 +276,12 @@ package final class TransportReceiver: Sendable { state.messageStartIndex = 0 let drainWaiters = state.drainWaiters state.drainWaiters.removeAll(keepingCapacity: false) - return drainWaiters + let drainWaiterRegistrationWaiters = state.drainWaiterRegistrationWaiters + state.drainWaiterRegistrationWaiters.removeAll(keepingCapacity: false) + return SealedWaiters( + drain: drainWaiters, + drainRegistration: drainWaiterRegistrationWaiters + ) } private static func takeReadyDrainWaiters( @@ -288,6 +323,11 @@ package final class TransportReceiver: Sendable { } } + private static func resumeSealedWaiters(_ waiters: SealedWaiters) { + resumeDrainWaiters(waiters.drain) + resume(waiters.drainRegistration) + } + private func compactMessagesIfNeeded(in state: inout State) { if state.messageStartIndex == state.messages.count { state.messages.removeAll(keepingCapacity: true) diff --git a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift index e5ad51b1..1815328c 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift @@ -23,6 +23,7 @@ func nativeInitialTargetDiscoveryAwaitsMainQueueCallbacksAndCoreDrain() async th } await parser.waitUntilBlocked() + await graph.receiver.waitForDrainWaiterForTesting() #expect(await completion.isFinished == false) #expect(await graph.core.snapshot().targetsByID.isEmpty) @@ -80,6 +81,7 @@ func nativeInitialTargetDiscoveryFailsWhenCloseInterruptsItsDrain() async { } await parser.waitUntilBlocked() + await graph.receiver.waitForDrainWaiterForTesting() let closeTask = Task { await graph.core.close() } From 87e2fa02ff8031e5bcbcb2f32abfcc5bece8020b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:24:44 +0900 Subject: [PATCH 17/81] refactor(data): move DOM state to its owner --- .../WebInspectorDataKit/DOMStateStore.swift | 1223 +++++++++++++++ .../WebInspectorContext.swift | 1315 ++++------------- .../DOMStateStoreTests.swift | 258 ++++ 3 files changed, 1740 insertions(+), 1056 deletions(-) create mode 100644 Sources/WebInspectorDataKit/DOMStateStore.swift create mode 100644 Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift diff --git a/Sources/WebInspectorDataKit/DOMStateStore.swift b/Sources/WebInspectorDataKit/DOMStateStore.swift new file mode 100644 index 00000000..207eecb9 --- /dev/null +++ b/Sources/WebInspectorDataKit/DOMStateStore.swift @@ -0,0 +1,1223 @@ +import WebInspectorProxyKit + +/// Owns the semantic DOM graph and every mutation that can change it. +/// +/// `WebInspectorContext` 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 + + 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 + nodesByID = [:] + frameDocumentProjectionIndex = FrameDocumentProjectionIndex() + treeStates = [] + pendingInspectedNodeID = nil + pageHighlightDocumentEpoch = nil + editHistoryTarget = nil + didInvalidateEditHistoryTarget = false + } + + package func node( + for id: DOMNode.ID, + isolation: isolated (any Actor) = #isolation + ) -> DOMNode? { + _ = isolation + return nodesByID[id] + } + + package func requiredNode( + for id: DOMNode.ID, + isolation: isolated (any Actor) = #isolation + ) throws -> DOMNode { + _ = isolation + guard let node = nodesByID[id] else { + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") + } + return node + } + + @discardableResult + package func registeredNode( + _ node: DOMNode, + isolation: isolated (any Actor) = #isolation + ) throws -> DOMNode { + _ = isolation + guard nodesByID[node.id] === node else { + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") + } + return node + } + + package func select( + _ node: DOMNode?, + reveal: DOMRevealPolicy, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + if let node, nodesByID[node.id] !== node { + preconditionFailure("DOMNode is not registered in this WebInspectorContext.") + } + pendingInspectedNodeID = nil + selectedNode = node + notifyDOMTreeSelectionChanged(node, reveal: reveal) + return Effects(statusChanged: true, selectionChanged: true) + } + + package func treeController( + root requestedRoot: DOMNode?, + isolation: isolated (any Actor) = #isolation + ) throws -> DOMTreeController { + _ = 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 { + _ = isolation + let tree = DOMTreeState(rootNode: rootNode, selectedNode: selectedNode) + treeStates.append(WeakDOMTreeState(tree)) + pruneReleasedTreeStates() + return DOMTreeController(tree: tree) + } + + package func currentTreeSnapshot( + isolation: isolated (any Actor) = #isolation + ) -> DOMTreeSnapshot { + _ = isolation + return DOMTreeSnapshot.make(revision: 0, rootNode: rootNode, selectedNode: selectedNode) + } + + package func currentTreeSnapshot( + containing nodes: [DOMNode], + isolation: isolated (any Actor) = #isolation + ) throws -> DOMTreeSnapshot { + _ = isolation + let snapshot = currentTreeSnapshot(isolation: isolation) + 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], + isolation: isolated (any Actor) = #isolation + ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { + _ = isolation + var seenNodeIDs: Set = [] + let nodes = try nodeIDs + .map { try requiredNode(for: $0, isolation: isolation) } + .filter { seenNodeIDs.insert($0.id).inserted } + let snapshot = try currentTreeSnapshot(containing: nodes, isolation: isolation) + 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], + isolation: isolated (any Actor) = #isolation + ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { + _ = isolation + var seenNodeIDs: Set = [] + let registeredNodes = try nodes + .map { try registeredNode($0, isolation: isolation) } + .filter { seenNodeIDs.insert($0.id).inserted } + let snapshot = try currentTreeSnapshot(containing: registeredNodes, isolation: isolation) + 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, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + 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, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + isElementPickerEnabled = enabled + return Effects(statusChanged: true) + } + + package func recordPageHighlight( + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + pageHighlightDocumentEpoch = documentEpoch + } + + package func clearPageHighlight( + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + pageHighlightDocumentEpoch = nil + } + + package func shouldSendPageHighlightClearAfterReset( + isolation: isolated (any Actor) = #isolation + ) -> Bool { + _ = isolation + return pageHighlightDocumentEpoch == nil + } + + package func recordEditHistoryTarget( + _ target: WebInspectorTarget, + options: WebInspectorMutationOptions, + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + guard options.undo == .automatic else { + return + } + editHistoryTarget = target + didInvalidateEditHistoryTarget = false + } + + package func undoRedoTarget( + capturedTarget: WebInspectorTarget?, + fallbackTarget: WebInspectorTarget?, + documentEpoch capturedDocumentEpoch: Int, + isolation: isolated (any Actor) = #isolation + ) throws -> WebInspectorTarget { + _ = isolation + 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( + isolation: isolated (any Actor) = #isolation + ) -> WebInspectorTarget? { + _ = isolation + return editHistoryTarget + } + + @discardableResult + package func advanceDocumentEpoch( + isolation: isolated (any Actor) = #isolation + ) -> Int { + _ = isolation + documentEpoch += 1 + return documentEpoch + } + + package func resetDocument( + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + 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: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + var effects = Effects() + switch event { + case .documentUpdated: + advanceDocumentEpoch(isolation: isolation) + effects.merge(resetDocument(isolation: isolation)) + effects.shouldReloadDocument = true + case let .setChildNodes(parent, nodes): + effects.merge(applySetChildNodes( + parent: parent, + nodes: nodes, + modelContext: modelContext, + isolation: isolation + )) + case let .childNodeInserted(parent, previous, node): + effects.merge(applyChildNodeInserted( + parent: parent, + previous: previous, + node: node, + modelContext: modelContext, + isolation: isolation + )) + 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 { + requestFrameDocumentIfNeeded( + forNodeID: inspectedNodeID, + reason: "DOM.inspect", + effects: &effects + ) + pendingInspectedNodeID = inspectedNodeID + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: true, + isolation: isolation + )) + return effects + } + pendingInspectedNodeID = nil + effects.merge(select(node, reveal: .selectAndScroll, isolation: isolation)) + 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, + isolation: isolation + )) + 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, + isolation: isolation + )) + case let .pseudoElementRemoved(parent, element): + effects.merge(applyPseudoElementRemoved(parent: parent, element: element)) + case .willDestroyDOMNode, + .unknown: + break + } + return effects + } + + @discardableResult + package func applyDocument( + _ payload: DOM.Node, + expectedEpoch: Int, + reason: DOMTreeSnapshotReason, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> Effects? { + _ = isolation + 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, + isolation: isolation + )) + return effects + } + + @discardableResult + package func applyFrameDocument( + _ document: DOM.Node, + frameTargetID: WebInspectorTarget.ID, + expectedEpoch: Int, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> Effects? { + _ = isolation + 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, + isolation: isolation + )) + return effects + } + + package func detachProjectedFrameDocument( + forFrameID frameID: FrameID, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + _ = isolation + 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: WebInspectorContext, + isolation: isolated (any Actor) + ) -> 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, + isolation: isolation + )) + return effects + } + + private func applyChildNodeInserted( + parent: DOM.Node.ID, + previous: DOM.Node.ID?, + node: DOM.Node, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) -> 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, + isolation: isolation + )) + 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: WebInspectorContext, + isolation: isolated (any Actor) + ) -> 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, + isolation: isolation + )) + 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: WebInspectorContext, + isolation: isolated (any Actor) + ) -> 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, + isolation: isolation + )) + 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: WebInspectorContext, + 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) + existing.setModelContext(modelContext) + node = existing + } else { + previousChildren = [] + previousAssociatedRoots = [] + node = DOMNode(node: payload, modelContext: modelContext) + 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, + isolation: isolated (any Actor) + ) -> 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, isolation: isolation)) + 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/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 03d331c5..6b10b55b 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -8,14 +8,21 @@ import WebInspectorProxyKit /// same actor when reading or mutating context-owned state. public final class WebInspectorContext { package struct DOMUndoRedoCommands { - private weak var context: WebInspectorContext? + private weak var store: DOMStateStore? private let target: WebInspectorTarget? - private let documentGeneration: Int - - fileprivate init(context: WebInspectorContext, target: WebInspectorTarget?, documentGeneration: Int) { - self.context = context + 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.documentGeneration = documentGeneration + self.fallbackTarget = fallbackTarget + self.documentEpoch = documentEpoch } package func undo(isolation: isolated (any Actor) = #isolation) async throws { @@ -27,23 +34,15 @@ public final class WebInspectorContext { } private func undoRedoTarget(isolation: isolated (any Actor)) throws -> WebInspectorTarget { - guard let context else { + guard let store 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() + return try store.undoRedoTarget( + capturedTarget: target, + fallbackTarget: fallbackTarget, + documentEpoch: documentEpoch, + isolation: isolation + ) } } @@ -102,6 +101,7 @@ public final class WebInspectorContext { private let proxy: WebInspectorProxy private let domainEnablement: WebInspectorDomainEnablementRegistry private let owner: any Actor + private let domState: DOMStateStore /// The current attachment state. public private(set) var state: State @@ -110,13 +110,19 @@ public final class WebInspectorContext { public private(set) var teardownError: WebInspectorProxyError? /// The current root DOM node, if a document is loaded. - public private(set) var rootNode: DOMNode? + public var rootNode: DOMNode? { + domState.rootNode + } /// The currently selected DOM node. - public private(set) var selectedNode: DOMNode? + public var selectedNode: DOMNode? { + domState.selectedNode + } /// A Boolean value indicating whether WebKit inspect mode is enabled. - public private(set) var isElementPickerEnabled: Bool + public var isElementPickerEnabled: Bool { + domState.isElementPickerEnabled + } /// Runtime execution contexts known to the current page. public private(set) var executionContexts: [RuntimeContext] @@ -126,14 +132,10 @@ public final class WebInspectorContext { 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? @@ -150,9 +152,6 @@ public final class WebInspectorContext { 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] @@ -175,9 +174,7 @@ public final class WebInspectorContext { 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)) { @@ -185,24 +182,17 @@ public final class WebInspectorContext { proxy = container.proxy domainEnablement = container.domainEnablement owner = isolation + domState = DOMStateStore() 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 @@ -219,9 +209,6 @@ public final class WebInspectorContext { networkTrackingTarget = nil runtimeTrackingTarget = nil consoleTrackingTarget = nil - nodesByID = [:] - frameDocumentProjectionIndex = FrameDocumentProjectionIndex() - treeStates = [] statusRelay = WebInspectorAsyncStreamRelay() requestsByID = [:] orderedRequestIDs = [] @@ -244,9 +231,7 @@ public final class WebInspectorContext { runtimeObjectIDsByProxyID = [:] runtimeObjectOwnersByID = [:] nextRuntimeObjectOrdinal = 0 - pendingInspectedNodeID = nil consoleObjectGroupReleaseTasks = [:] - pageHighlightDocumentGeneration = nil WebInspectorDataKitLog.debug("context state=\(state.logDescription)") } @@ -269,7 +254,6 @@ public final class WebInspectorContext { currentPageRetargetTask?.cancel() currentPageCleanupTask?.cancel() documentReloadTask?.cancel() - inspectResolutionTask?.cancel() inspectedNodeHighlightTask?.cancel() cancelFrameDocumentLoadTasks() styleRefreshTask?.cancel() @@ -324,15 +308,12 @@ public final class WebInspectorContext { /// 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] + return domState.node(for: id, isolation: isolation) } 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 + return try domState.requiredNode(for: id, isolation: isolation) } /// Returns the registered Network request for an identifier. @@ -382,18 +363,10 @@ public final class WebInspectorContext { 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) + let effects = domState.select(node, reveal: reveal, isolation: isolation) + applyDOMStateEffects(effects, isolation: isolation) } package func selectNode(_ id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws { @@ -455,14 +428,8 @@ public final class WebInspectorContext { 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 deletion = try domState.sortedDeletionNodes(for: nodeIDs, isolation: isolation) + let sortedNodes = deletion.nodes let deletionTargets = try validatedDeletionTargets(for: sortedNodes) var acceptedNodeIDs: [DOMNode.ID] = [] for (node, target) in zip(sortedNodes, deletionTargets) { @@ -473,7 +440,14 @@ public final class WebInspectorContext { acceptedNodeIDs.append(node.id) } catch { if acceptedNodeIDs.isEmpty == false { - clearSelectionIfDeleted(acceptedNodeIDs, snapshot: snapshot, isolation: isolation) + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + acceptedNodeIDs, + snapshot: deletion.snapshot, + isolation: isolation + ), + isolation: isolation + ) throw DOMDeletionPartialFailure( deletedNodeCount: acceptedNodeIDs.count, underlyingError: error @@ -482,7 +456,14 @@ public final class WebInspectorContext { throw error } } - clearSelectionIfDeleted(acceptedNodeIDs, snapshot: snapshot, isolation: isolation) + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + acceptedNodeIDs, + snapshot: deletion.snapshot, + isolation: isolation + ), + isolation: isolation + ) return DOMMutationResult(requestedNodeIDs: nodeIDs, acceptedNodeIDs: acceptedNodeIDs) } @@ -499,9 +480,15 @@ public final class WebInspectorContext { 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) + return try domState.currentTreeSnapshot( + containing: [node], + isolation: isolation + ).selectorPath(for: node.id) case .xPath: - return try currentDOMTreeSnapshot(containing: [node]).xPath(for: node.id) + return try domState.currentTreeSnapshot( + containing: [node], + isolation: isolation + ).xPath(for: node.id) } } @@ -529,15 +516,8 @@ public final class WebInspectorContext { 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 deletion = try domState.sortedDeletionNodes(for: nodes, isolation: isolation) + let sortedNodes = deletion.nodes let deletionTargets = try validatedDeletionTargets(for: sortedNodes) var removedNodes: [DOMNode] = [] for (node, target) in zip(sortedNodes, deletionTargets) { @@ -548,7 +528,14 @@ public final class WebInspectorContext { removedNodes.append(node) } catch { if removedNodes.isEmpty == false { - clearSelectionIfDeleted(removedNodes.map(\.id), snapshot: snapshot, isolation: isolation) + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + removedNodes.map(\.id), + snapshot: deletion.snapshot, + isolation: isolation + ), + isolation: isolation + ) throw DOMDeletionPartialFailure( deletedNodeCount: removedNodes.count, underlyingError: error @@ -557,7 +544,14 @@ public final class WebInspectorContext { throw error } } - clearSelectionIfDeleted(removedNodes.map(\.id), snapshot: snapshot, isolation: isolation) + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + removedNodes.map(\.id), + snapshot: deletion.snapshot, + isolation: isolation + ), + isolation: isolation + ) return removedNodes.count } @@ -573,7 +567,7 @@ public final class WebInspectorContext { var seenNodeIDs: Set = [] let nodes = try nodeIDs .filter { seenNodeIDs.insert($0).inserted } - .map { try requiredNode(for: $0, isolation: isolation) } + .map { try domState.requiredNode(for: $0, isolation: isolation) } return try await deleteCountingRemovedNodes(nodes, isolation: isolation) } @@ -622,7 +616,7 @@ public final class WebInspectorContext { try registeredNode(node) let page = try currentPageOrThrow() if node.id.proxyID.targetScopeRawValue == nil { - recordPageHighlight(documentGeneration: domDocumentGeneration, isolation: isolation) + domState.recordPageHighlight(isolation: isolation) } try await page.dom.highlightNode(node.id.proxyID) } @@ -636,15 +630,16 @@ public final class WebInspectorContext { requireOwner(isolation) let page = try currentPageOrThrow() try await page.dom.hideHighlight() - pageHighlightDocumentGeneration = nil + domState.clearPageHighlight(isolation: isolation) } package func domUndoRedoCommands(isolation: isolated (any Actor) = #isolation) throws -> DOMUndoRedoCommands { requireOwner(isolation) return DOMUndoRedoCommands( - context: self, - target: domEditHistoryTarget, - documentGeneration: domDocumentGeneration + store: domState, + target: domState.capturedEditHistoryTarget(isolation: isolation), + fallbackTarget: currentPage, + documentEpoch: domState.documentEpoch ) } @@ -674,8 +669,10 @@ public final class WebInspectorContext { ) throw error } - isElementPickerEnabled = isEnabled - notifyStatusChanged() + applyDOMStateEffects( + domState.setElementPickerEnabled(isEnabled, isolation: isolation), + isolation: isolation + ) WebInspectorDataKitLog.debug( "DOM picker setInspectMode finished enabled=\(isEnabled) target=\(page.id.rawValue)" ) @@ -695,7 +692,10 @@ public final class WebInspectorContext { 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) + return try domState.currentTreeSnapshot( + containing: [node], + isolation: isolation + ).selectorPath(for: node.id) } package func selectorPath(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> String { @@ -706,7 +706,10 @@ public final class WebInspectorContext { 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) + return try domState.currentTreeSnapshot( + containing: [node], + isolation: isolation + ).xPath(for: node.id) } package func xPath(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> String { @@ -719,25 +722,12 @@ public final class WebInspectorContext { 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) + return try domState.treeController(root: requestedRoot, isolation: isolation) } 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) + return domState.rootTreeController(isolation: isolation) } /// Selects the Runtime execution context used by default evaluation calls. @@ -974,7 +964,7 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) async { requireOwner(isolation) - guard nodesByID[node.id] === node else { + guard (try? domState.registeredNode(node, isolation: isolation)) != nil else { skipEvent("requestChildren ignored a DOMNode from a previous document generation") return } @@ -1170,8 +1160,6 @@ public final class WebInspectorContext { currentPageCleanupTask = nil documentReloadTask?.cancel() documentReloadTask = nil - inspectResolutionTask?.cancel() - inspectResolutionTask = nil inspectedNodeHighlightTask?.cancel() inspectedNodeHighlightTask = nil cancelFrameDocumentLoadTasks() @@ -1184,11 +1172,9 @@ public final class WebInspectorContext { styleToggleTasks = [:] stopEventPumps() cancelConsoleObjectGroupReleaseTasks() - pendingInspectedNodeID = nil - isElementPickerEnabled = false currentPage = nil advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) + domState.advanceDocumentEpoch(isolation: isolation) resetAttachmentBackedModels(isolation: isolation) teardownError = nil teardownError = await disableEnabledDomains(isolation: isolation) @@ -1266,7 +1252,11 @@ public final class WebInspectorContext { guard isCurrentPageGeneration(generation, isolation: isolation) else { return } - applyDocument(document.node, isolation: isolation) + applyDocument( + document.node, + expectedEpoch: document.generation, + isolation: isolation + ) transition(to: .attached) } catch is CancellationError { await disableEnabledDomainsAfterCancellation(isolation: isolation) @@ -1407,12 +1397,12 @@ public final class WebInspectorContext { ) async throws -> LoadedDOMDocument { _ = isolation while true { - let generation = domDocumentGeneration + let generation = domState.documentEpoch let document = try await target.dom.getDocument() guard Task.isCancelled == false else { throw CancellationError() } - guard isDOMDocumentGeneration(generation, isolation: isolation) else { + guard generation == domState.documentEpoch else { continue } return (node: document, generation: generation) @@ -1424,7 +1414,7 @@ public final class WebInspectorContext { on target: WebInspectorTarget, isolation: isolated (any Actor) ) async throws -> LoadedDOMDocument { - if isDOMDocumentGeneration(document.generation, isolation: isolation) { + if document.generation == domState.documentEpoch { return document } return try await loadCurrentDOMDocument(on: target, isolation: isolation) @@ -1616,108 +1606,12 @@ public final class WebInspectorContext { #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 registeredNode( + _ node: DOMNode, + isolation: isolated (any Actor) = #isolation + ) throws -> DOMNode { + try domState.registeredNode(node, isolation: isolation) } private func currentPageOrThrow() throws -> WebInspectorTarget { @@ -1767,12 +1661,12 @@ public final class WebInspectorContext { } } - private func recordDOMEditHistoryTarget(_ target: WebInspectorTarget, options: WebInspectorMutationOptions) { - guard options.undo == .automatic else { - return - } - domEditHistoryTarget = target - didInvalidateDOMEditHistoryTarget = false + private func recordDOMEditHistoryTarget( + _ target: WebInspectorTarget, + options: WebInspectorMutationOptions, + isolation: isolated (any Actor) = #isolation + ) { + domState.recordEditHistoryTarget(target, options: options, isolation: isolation) } private func validatedDeletionTargets(for nodes: [DOMNode]) throws -> [WebInspectorTarget] { @@ -1793,53 +1687,6 @@ public final class WebInspectorContext { 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) @@ -1848,14 +1695,6 @@ public final class WebInspectorContext { 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 @@ -1863,17 +1702,6 @@ public final class WebInspectorContext { 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: @@ -1892,14 +1720,6 @@ public final class WebInspectorContext { 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`. @@ -1936,6 +1756,52 @@ public final class WebInspectorContext { statusRelay.yield(status) } + private func applyDOMStateEffects( + _ effects: DOMStateStore.Effects, + isolation: isolated (any Actor) + ) { + if effects.documentReset { + documentReloadTask?.cancel() + documentReloadTask = nil + inspectedNodeHighlightTask?.cancel() + inspectedNodeHighlightTask = nil + cancelFrameDocumentLoadTasks() + } + + if effects.selectionChanged || effects.documentReset { + styleRefreshTask?.cancel() + styleRefreshTask = nil + styleRefreshGeneration += 1 + } + effects.discardedStyleNode?.setElementStyles(nil) + + if effects.statusChanged { + notifyStatusChanged() + } + if effects.selectionChanged { + refreshSelectedStyles(isolation: isolation) + } else if effects.selectedStylesNeedRefresh { + markSelectedStylesNeedsRefresh(isolation: isolation) + } + + if effects.shouldClearPageHighlight { + clearPageHighlightForDOMReset(isolation: isolation) + } + for request in effects.frameDocumentLoadRequests { + loadFrameDocumentIfNeeded( + forFrameTargetID: request.targetID, + reason: request.reason, + isolation: isolation + ) + } + if let inspectedNode = effects.inspectedNode { + restoreElementPickerHighlight(for: inspectedNode, isolation: isolation) + } + if effects.shouldReloadDocument, state != .attaching { + reloadDocument(isolation: isolation) + } + } + func reloadDocument(isolation: isolated (any Actor) = #isolation) { requireOwner(isolation) guard let currentPage else { @@ -1943,7 +1809,7 @@ public final class WebInspectorContext { return } - let generation = domDocumentGeneration + let generation = domState.documentEpoch documentReloadTask?.cancel() documentReloadTask = Task { [weak self, currentPage, generation] in _ = isolation @@ -1952,10 +1818,15 @@ public final class WebInspectorContext { guard Task.isCancelled == false else { return } - guard self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { + guard self?.domState.documentEpoch == generation else { return } - self?.applyDocument(document, reason: .documentUpdated, isolation: isolation) + self?.applyDocument( + document, + expectedEpoch: generation, + reason: .documentUpdated, + isolation: isolation + ) } catch is CancellationError { return } catch { @@ -2007,7 +1878,7 @@ extension WebInspectorContext { documentReloadTask?.cancel() documentReloadTask = nil let generation = advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) + domState.advanceDocumentEpoch(isolation: isolation) resetCurrentPageLifecycleModels(isolation: isolation) cancelConsoleObjectGroupReleaseTasks() for task in styleToggleTasks.values { @@ -2061,7 +1932,12 @@ extension WebInspectorContext { guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { return } - applyDocument(document.node, reason: .pageChanged, isolation: isolation) + applyDocument( + document.node, + expectedEpoch: document.generation, + reason: .pageChanged, + isolation: isolation + ) if case .attaching = state { transition(to: .attached) } @@ -2084,10 +1960,13 @@ extension WebInspectorContext { return } guard frame.parentID == nil || frame.id == currentPage.frameID else { - detachProjectedFrameDocument(forFrameID: frame.id, isolation: isolation) + applyDOMStateEffects( + domState.detachProjectedFrameDocument(forFrameID: frame.id, isolation: isolation), + isolation: isolation + ) return } - advanceDOMDocumentGeneration(isolation: isolation) + domState.advanceDocumentEpoch(isolation: isolation) resetDOM(isolation: isolation) clearExecutionContexts() guard currentPageRetargetTask == nil, @@ -2101,7 +1980,10 @@ extension WebInspectorContext { _ frameID: FrameID, isolation: isolated (any Actor) ) { - detachProjectedFrameDocument(forFrameID: frameID, isolation: isolation) + applyDOMStateEffects( + domState.detachProjectedFrameDocument(forFrameID: frameID, isolation: isolation), + isolation: isolation + ) } private func applyCurrentPageTargetDestroyed( @@ -2132,7 +2014,7 @@ extension WebInspectorContext { styleToggleTasks = [:] cancelConsoleObjectGroupReleaseTasks() let generation = advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) + domState.advanceDocumentEpoch(isolation: isolation) currentPageRetargetTask = Task { [weak self, generation] in _ = isolation @@ -2191,137 +2073,51 @@ extension WebInspectorContext { 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 - } + let effects = domState.apply(event, modelContext: self, isolation: isolation) + applyDOMStateEffects(effects, isolation: isolation) } func applyDocument( _ node: DOM.Node, + expectedEpoch: Int, 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) + guard let effects = domState.applyDocument( + node, + expectedEpoch: expectedEpoch, + reason: reason, + modelContext: self, + isolation: isolation + ) else { + return + } + applyDOMStateEffects(effects, isolation: isolation) } - package func seedDOMDocument(_ node: DOM.Node, isolation: isolated (any Actor) = #isolation) { + package func seedDOMDocument( + _ node: DOM.Node, + isolation: isolated (any Actor) = #isolation + ) { + let reason: DOMTreeSnapshotReason = rootNode == nil ? .initialDocument : .documentUpdated applyDocument( node, - reason: rootNode == nil ? .initialDocument : .documentUpdated, + expectedEpoch: domState.documentEpoch, + reason: reason, isolation: isolation ) } - package func seedElementPickerEnabled(_ isEnabled: Bool, isolation: isolated (any Actor) = #isolation) { + package func seedElementPickerEnabled( + _ isEnabled: Bool, + isolation: isolated (any Actor) = #isolation + ) { requireOwner(isolation) - isElementPickerEnabled = isEnabled - notifyStatusChanged() + applyDOMStateEffects( + domState.setElementPickerEnabled(isEnabled, isolation: isolation), + isolation: isolation + ) } /// Seeds the selected element node's styles through the same load path @@ -2334,7 +2130,7 @@ extension WebInspectorContext { isolation: isolated (any Actor) = #isolation ) { requireOwner(isolation) - guard let selectedNode else { + guard let selectedNode = domState.selectedNode else { preconditionFailure("seedSelectedNodeStyles requires a selected node.") } guard selectedNode.nodeType == 1 else { @@ -2353,52 +2149,21 @@ extension WebInspectorContext { } 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 + let effects = domState.resetDocument(isolation: isolation) + applyDOMStateEffects(effects, isolation: isolation) } 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 { + guard self?.domState.shouldSendPageHighlightClearAfterReset(isolation: isolation) == true else { return } WebInspectorDataKitLog.debug("DOM reset clearing page highlight") @@ -2411,401 +2176,6 @@ extension WebInspectorContext { } } - 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, @@ -2814,154 +2184,42 @@ extension WebInspectorContext { guard frameDocumentLoadTasks[frameTargetID] == nil else { return } - let generation = domDocumentGeneration + let epoch = domState.documentEpoch 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 + frameDocumentLoadTasks[frameTargetID] = Task { [weak self, target, frameTargetID, epoch] in _ = isolation do { let document = try await target.dom.getDocument() guard Task.isCancelled == false, - self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { + self?.domState.documentEpoch == epoch, + let self, + let effects = self.domState.applyFrameDocument( + document, + frameTargetID: frameTargetID, + expectedEpoch: epoch, + modelContext: self, + isolation: isolation + ) else { return } - self?.applyFrameDocument(document, frameTargetID: frameTargetID, isolation: isolation) + self.applyDOMStateEffects(effects, isolation: isolation) } catch is CancellationError { return } catch { - guard self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { + guard self?.domState.documentEpoch == epoch else { return } self?.failIfTerminal(error, operation: "frame DOM.getDocument") } - if self?.isDOMDocumentGeneration(generation, isolation: isolation) == true { + if self?.domState.documentEpoch == epoch { 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() @@ -2969,69 +2227,15 @@ extension WebInspectorContext { 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, + private func restoreElementPickerHighlight( + for node: DOMNode, 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 epoch = domState.documentEpoch let nodeID = node.id.proxyID guard nodeID.targetScopeRawValue == nil else { return @@ -3039,17 +2243,17 @@ extension WebInspectorContext { 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 + inspectedNodeHighlightTask = Task { [weak self, currentPage, epoch, nodeID] in _ = isolation do { guard Task.isCancelled == false, - self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { + self?.domState.documentEpoch == epoch else { return } WebInspectorDataKitLog.debug( "DOM.inspect restoring highlight nodeID=\(String(describing: nodeID))" ) - self?.recordPageHighlight(documentGeneration: generation, isolation: isolation) + self?.domState.recordPageHighlight(isolation: isolation) try await currentPage.dom.highlightNode(nodeID) } catch is CancellationError { return @@ -3059,7 +2263,6 @@ extension WebInspectorContext { } } } - extension WebInspectorContext { private struct SelectedStylePayloads { var matchedStyles: CSS.MatchedStyles @@ -3086,7 +2289,7 @@ extension WebInspectorContext { styleRefreshTask?.cancel() styleRefreshTask = nil - guard let selectedNode else { + guard let selectedNode = domState.selectedNode else { return } guard selectedNode.nodeType == 1 else { @@ -3230,7 +2433,7 @@ extension WebInspectorContext { } private func isCurrentStyleRefresh(node: DOMNode, generation: Int) -> Bool { - Task.isCancelled == false && selectedNode === node && styleRefreshGeneration == generation + Task.isCancelled == false && domState.selectedNode === node && styleRefreshGeneration == generation } /// Reports whether the style pane is visible. While active, stale @@ -3242,7 +2445,7 @@ extension WebInspectorContext { return } isStyleHydrationActive = active - guard active, let styles = selectedNode?.elementStyles else { + guard active, let styles = domState.selectedNode?.elementStyles else { return } switch styles.phase { @@ -3255,7 +2458,7 @@ extension WebInspectorContext { package func styles(for nodeID: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> CSSStyles { requireOwner(isolation) - let node = try requiredNode(for: nodeID, isolation: isolation) + let node = try domState.requiredNode(for: nodeID, isolation: isolation) guard node.nodeType == 1 else { throw WebInspectorProxyError.commandFailed( domain: "CSS", @@ -3263,7 +2466,7 @@ extension WebInspectorContext { message: "CSS styles are only available for element DOM nodes." ) } - if selectedNode !== node { + if domState.selectedNode !== node { select(node, isolation: isolation) } guard let styles = node.elementStyles else { @@ -3286,7 +2489,7 @@ extension WebInspectorContext { ) async throws { requireOwner(isolation) guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, + let styles = domState.selectedNode?.elementStyles, let intent = styles.setStyleTextIntent(for: id, enabled: enabled) else { throw WebInspectorProxyError.commandFailed( domain: "CSS", @@ -3304,7 +2507,7 @@ extension WebInspectorContext { let target = try cssTarget(owning: intent.styleID) let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - recordDOMEditHistoryTarget(target, options: options) + domState.recordEditHistoryTarget(target, options: options, isolation: isolation) try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) styles.applySetStyleText(result: result, for: id) refreshSelectedStylesIfHydrationActive(isolation: isolation) @@ -3318,7 +2521,7 @@ extension WebInspectorContext { ) async throws { requireOwner(isolation) guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, + let styles = domState.selectedNode?.elementStyles, let intent = styles.setDeclarationTextIntent(for: id, text: text) else { throw WebInspectorProxyError.commandFailed( domain: "CSS", @@ -3336,7 +2539,7 @@ extension WebInspectorContext { let target = try cssTarget(owning: intent.styleID) let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - recordDOMEditHistoryTarget(target, options: options) + domState.recordEditHistoryTarget(target, options: options, isolation: isolation) try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) styles.applySetStyleText(result: result, for: id) refreshSelectedStylesIfHydrationActive(isolation: isolation) @@ -3352,7 +2555,7 @@ extension WebInspectorContext { let proxyID = id.proxyID let target = try cssTarget(owning: proxyID) _ = try await target.css.setRuleSelector(proxyID, selector: selector) - recordDOMEditHistoryTarget(target, options: options) + domState.recordEditHistoryTarget(target, options: options, isolation: isolation) try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) refreshSelectedStylesIfHydrationActive(isolation: isolation) } @@ -3366,7 +2569,7 @@ extension WebInspectorContext { requireOwner(isolation) let target = try cssTarget(owning: id) try await target.css.setStyleSheetText(id, text: text) - recordDOMEditHistoryTarget(target, options: options) + domState.recordEditHistoryTarget(target, options: options, isolation: isolation) try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) refreshSelectedStylesIfHydrationActive(isolation: isolation) } @@ -3385,7 +2588,7 @@ extension WebInspectorContext { ) -> Bool { requireOwner(isolation) guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, + let styles = domState.selectedNode?.elementStyles, let intent = styles.setStyleTextIntent(for: id, enabled: enabled) else { return false } @@ -3408,7 +2611,7 @@ extension WebInspectorContext { guard Task.isCancelled == false else { return } - self.recordDOMEditHistoryTarget(target, options: options) + self.domState.recordEditHistoryTarget(target, options: options, isolation: isolation) try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) styles.applySetStyleText(result: result, for: id) self.refreshSelectedStylesIfHydrationActive(isolation: isolation) @@ -3440,7 +2643,7 @@ extension WebInspectorContext { for nodeID: DOMNode.ID, isolation: isolated (any Actor) = #isolation ) { - guard selectedNode?.id == nodeID else { + guard domState.selectedNode?.id == nodeID else { return } markSelectedStylesNeedsRefresh(isolation: isolation) @@ -3448,7 +2651,7 @@ extension WebInspectorContext { private func markSelectedStylesNeedsRefresh(isolation: isolated (any Actor) = #isolation) { styleRefreshGeneration += 1 - guard let styles = selectedNode?.elementStyles else { + guard let styles = domState.selectedNode?.elementStyles else { return } styles.markNeedsRefresh() diff --git a/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift new file mode 100644 index 00000000..0868e670 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift @@ -0,0 +1,258 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func domStateStorePreservesNodeIdentityAcrossPayloadUpdates() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + #expect(applied != nil) + let original = try #require(store.node(for: DOMNode.ID(elementID))) + + _ = store.apply( + .attributeModified(elementID, name: "class", value: "after"), + modelContext: context, + isolation: MainActor.shared + ) + + #expect(store.node(for: DOMNode.ID(elementID)) === original) + #expect(original.attributes["class"] == "after") +} + +@MainActor +@Test +func domStateStoreDocumentResetAdvancesEpochAndClearsSemanticState() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + let child = try #require(store.node(for: DOMNode.ID(childID))) + _ = store.select(child, reveal: .none, isolation: MainActor.shared) + _ = store.setElementPickerEnabled(true, isolation: MainActor.shared) + let previousEpoch = store.documentEpoch + + let effects = store.apply( + .documentUpdated, + modelContext: context, + isolation: MainActor.shared + ) + + #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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + + let applied = store.applyFrameDocument( + DOM.Node( + id: frameDocumentID, + nodeType: 9, + nodeName: "#document", + frameID: frameID + ), + frameTargetID: frameTargetID, + expectedEpoch: store.documentEpoch, + modelContext: context, + isolation: MainActor.shared + ) + #expect(applied != nil) + + let scopedDocumentID = DOMNode.ID(DOM.Node.ID( + frameDocumentID.rawValue, + scopedToTargetRawValue: frameTargetID.rawValue + )) + let snapshot = store.currentTreeSnapshot(isolation: MainActor.shared) + #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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + let controller = store.rootTreeController(isolation: MainActor.shared) + 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, isolation: MainActor.shared) + + #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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + let controller = store.rootTreeController(isolation: MainActor.shared) + let initialRevision = controller.revision + var iterator = controller.updates.makeAsyncIterator() + + _ = store.apply( + .attributeModified(elementID, name: "class", value: "updated"), + modelContext: context, + isolation: MainActor.shared + ) + + 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 = WebInspectorContext.preview(isolation: MainActor.shared) + let store = DOMStateStore() + let staleEpoch = store.documentEpoch + store.advanceDocumentEpoch(isolation: MainActor.shared) + _ = store.resetDocument(isolation: MainActor.shared) + + let applied = store.applyDocument( + DOM.Node(id: DOM.Node.ID("stale-document"), nodeType: 9, nodeName: "#document"), + expectedEpoch: staleEpoch, + reason: .documentUpdated, + modelContext: context, + isolation: MainActor.shared + ) + + #expect(applied == nil) + #expect(store.rootNode == nil) +} + +@MainActor +@Test +func domStateStoreKeepsOnlyWeakTreeRegistrations() { + let store = DOMStateStore() + weak var releasedController: DOMTreeController? + + do { + let controller = store.rootTreeController(isolation: MainActor.shared) + releasedController = controller + #expect(releasedController != nil) + } + + #expect(releasedController == nil) + _ = store.resetDocument(isolation: MainActor.shared) +} From bfa8b4155563ca84396fa9035eabbd5d7d71e3bc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:59:09 +0900 Subject: [PATCH 18/81] fix(monocly): break inspector attachment task cycle --- ...rInspectorSessionAttachmentLifecycle.swift | 110 ++++++++++++------ .../BrowserSessionRestoreTests.swift | 101 +++++++++++++++- 2 files changed, 176 insertions(+), 35 deletions(-) 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 } From a0fdeffae1f7375a035ede48f47d32e9a71abcff Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:12:06 +0900 Subject: [PATCH 19/81] feat(proxy): add a bounded ordered model feed --- Docs/WebInspectorKitsArchitecture.md | 102 ++- .../WebInspectorContext.swift | 6 +- .../Transport/ConnectionModelFeed.swift | 394 ++++++++ .../Transport/TransportSession.swift | 692 ++++++++++++-- .../Transport/TransportTargetRegistry.swift | 77 ++ .../WebInspectorProxy.swift | 10 + .../WebInspectorProxyError.swift | 3 + .../WebInspectorTarget.swift | 2 +- .../NetworkBodyViewController.swift | 2 +- .../ConnectionModelFeedTests.swift | 867 ++++++++++++++++++ 10 files changed, 2045 insertions(+), 110 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift create mode 100644 Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index aded2a29..32b44291 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -548,12 +548,17 @@ 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, domain, and a replay-complete boundary derived from the enable -reply watermark: +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, @@ -581,12 +586,65 @@ package enum ConnectionModelFeedRecord: Sendable { ) } -package enum ModelProtocolEvent: Sendable { /* typed domain payloads */ } -package enum ModelBootstrapSnapshot: Sendable { /* typed snapshots */ } +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 enum ModelProtocolEvent: Sendable { /* physical target + typed payload */ } +package enum ModelBootstrapSnapshot: Sendable { /* target + epoch + typed snapshot */ } package enum ModelDomain: Hashable, Sendable { /* configured domains */ } ``` -One DataKit feed consumer applies records serially. Reaching +The initial transport slice implements the bounded exclusive feed, initial +`reset`/`targetSnapshot`, future target lifecycle deltas, and future events for +configured domains. For an empty configured-domain set it also emits +`synchronizationComplete`. Capability leases, replay-complete boundaries, DOM +bootstrap, configured-domain synchronization, and the full retarget sequence +described below remain later slices; their record cases define the package +contract but are not yet emitted. This distinction prevents schema availability +from being mistaken for readiness behavior that the transport does not yet +provide. + +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 a bounded, oldest-first, single-consumer sequence. Overflow clears +pending records and immediately poisons both the feed and connection; there is +no silent drop, task relay, or unbounded default. 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 and +releases the claim only after a clean close, allowing a replacement feed. +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. Once a client +command owns a pending reply or a structured scope is admitted, a feed cannot +later reconstruct the missed prefix. Conversely, while a feed owns the +connection, public 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. + +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 @@ -595,21 +653,22 @@ 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. -Opening the feed registers it and its initial reset record before acquiring any -configured capability. Capability acquisition is transactional: if one enable -fails, the core releases every capability acquired for that attempt, terminates -the feed with the cause, and lets DataKit fail attachment. 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`. Its +The completed design opens the feed and registers its initial reset record +before acquiring any configured capability. Capability acquisition will be +transactional: if one enable fails, the core releases every capability acquired +for that attempt, terminates the feed with the cause, and lets DataKit fail +attachment. 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. + +The completed DOM readiness slice will use the same ordered feed even though DOM +has no enable-time replay. After the feed is registered, the core will issue +`DOM.getDocument`. 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 is followed by `bootstrapComplete`. +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 @@ -656,7 +715,9 @@ old scoped ID to a current-generation command fails locally with When no physical page is temporarily committed, a command fails with `pageUnavailable`; it does not guess a stale target. During commit the core -performs one ordered transition: +will eventually perform the following full ordered transition. The current +slice establishes only its synchronous reset, physical target snapshot, and +future-delta prefix: 1. stop admission of new target-scoped commands and increment page generation; 2. publish `.reset(newGeneration)` to public scopes and the package model feed; @@ -712,9 +773,10 @@ public enum WebInspectorProxyError: Error, Sendable { - 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 also bounded. Overflow terminates the model -attachment and moves its context to `.failed`; it cannot fabricate a full - domain resynchronization. +- The package model feed is also bounded. Overflow currently terminates the feed + and connection. The later DataKit driver will map that terminal failure to a + failed model attachment; neither layer may fabricate 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. diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 6b10b55b..2f1cb159 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -1727,11 +1727,13 @@ public final class WebInspectorContext { switch error { case let proxyError as WebInspectorProxyError: switch proxyError { - case .disconnected, .unsupported, .attachFailed, .protocolViolation, .transportFailure: + case .disconnected, .unsupported, .attachFailed, .protocolViolation, + .transportFailure: fail(proxyError) case .closed, .pageUnavailable: WebInspectorDataKitLog.debug("\(operation) raced connection close") - case .staleIdentifier, .commandFailed, .commandRejected, .eventBufferOverflow, .timeout: + case .staleIdentifier, .commandFailed, .commandRejected, + .eventBufferOverflow, .connectionInUse, .timeout: WebInspectorDataKitLog.debug("\(operation) failed: \(String(describing: proxyError))") } default: diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift new file mode 100644 index 00000000..9a138b22 --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -0,0 +1,394 @@ +import Foundation +import Synchronization + +package enum ModelDomain: Hashable, Sendable { + case dom + case css + case network + case console + case 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 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 + ) + 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 overflow + case terminated +} + +package enum ConnectionModelFeedError: Error, Equatable, Sendable { + case connectionAlreadyUsedByDirectConsumer + case alreadyOpen + case bufferOverflow(capacity: Int) + 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 + } + } + + package let capacity: Int + private let state = Mutex(State()) + + package init(capacity: Int) { + precondition(capacity > 0, "A bounded model feed must have a positive capacity.") + self.capacity = capacity + } + + 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) + } + guard state.pendingRecordCount < capacity else { + state.discardPendingRecords() + state.terminal = .failed( + ConnectionModelFeedError.bufferOverflow(capacity: capacity) + ) + return (.overflow, nil) + } + 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) + } + + isolated deinit { + mailbox.abandon() + } +} diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index be157e22..1b4bc19e 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -46,6 +46,32 @@ private struct PhysicalTargetDisappearanceWaiters: Sendable { var release: [ReplyPromise] = [] } +private struct ConnectionModelFeedRegistration: Sendable { + let id: ConnectionModelFeedID + let configuredDomains: Set + let mailbox: ConnectionModelFeedMailbox + var targetSnapshotThrough: UInt64? + var resetGeneration: WebInspectorPage.Generation +} + +private struct CurrentPageBindingChangeEffects: Sendable { + var capabilityKeysToReconcile: [ConnectionCapabilityKey] = [] + var releaseWaiters: [ReplyPromise] = [] +} + +private struct RootEventMutation: Sendable { + var pendingStyleSheetEvents: [ResolvedStyleSheetAddedEvent] = [] + var bindingEffects = CurrentPageBindingChangeEffects() + var physicalTargetWaiters = PhysicalTargetDisappearanceWaiters() + var pendingRepliesToFail: [TransportSession.PendingReply] = [] + var missingTargetID: ProtocolTarget.ID? +} + +private struct MainPageTargetNotification: Sendable { + let waiters: [ReplyPromise] + let result: TransportSession.MainPageTarget +} + /// Owns one physical inspector connection. /// /// Target membership, command/reply routing, inbound ordering, and terminal @@ -61,6 +87,7 @@ package actor ConnectionCore { case explicitClose case fatal(String) case protocolViolation(String) + case modelFeedFailure(ConnectionModelFeedError) } private enum State { @@ -142,6 +169,8 @@ package actor ConnectionCore { 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 capabilityTasks: [UInt64: ConnectionCapabilityTask] private var currentPageGeneration: WebInspectorPage.Generation @@ -160,6 +189,7 @@ package actor ConnectionCore { private var closeWaiterRegistrationWaiters: [CheckedContinuation] private var cancelledCloseWaiterIDs: Set private var nextTestTargetOrdinal: UInt64 + private var modelTargetMutationActionForTesting: (@Sendable () -> Void)? package init( backend: any TransportBackend, @@ -187,6 +217,8 @@ package actor ConnectionCore { runtimeContextRegistry = RuntimeContextRegistry() eventSubscribers = TransportEventSubscriberRegistry() eventScopes = ConnectionEventScopeRegistry() + modelFeed = nil + replayWasTaintedByDirectConsumer = false capabilities = ConnectionCapabilityRegistry() capabilityTasks = [:] currentPageGeneration = WebInspectorPage.Generation(rawValue: 0) @@ -204,6 +236,7 @@ package actor ConnectionCore { closeWaiterRegistrationWaiters = [] cancelledCloseWaiterIDs = [] nextTestTargetOrdinal = 0 + modelTargetMutationActionForTesting = nil } isolated deinit { @@ -212,6 +245,8 @@ package actor ConnectionCore { // resources; native resources have their own isolated backstop. eventSubscribers.finishAndRemoveAll() eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) + modelFeed?.mailbox.finish(throwing: WebInspectorProxyError.closed) + modelFeed = nil terminalTask?.cancel() terminalTask = nil let tasks = Array(capabilityTasks.values) @@ -251,6 +286,7 @@ package actor ConnectionCore { guard isOpen else { return finishedStream(of: ProtocolEvent.self) } + claimLegacyPassiveConsumer() let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) let subscriberID = eventSubscribers.insert(pair.continuation, domain: domain) pair.continuation.onTermination = { [weak self] _ in @@ -265,6 +301,7 @@ package actor ConnectionCore { guard isOpen else { return finishedStream(of: ProtocolEvent.self) } + claimLegacyPassiveConsumer() let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) let subscriberID = eventSubscribers.insertOrdered(pair.continuation) pair.continuation.onTermination = { [weak self] _ in @@ -275,6 +312,62 @@ package actor ConnectionCore { return pair.stream } + package func openModelFeed( + configuredDomains: Set, + capacity: Int + ) throws -> ConnectionModelFeed { + precondition(capacity > 0, "A bounded model feed must have a positive capacity.") + guard isOpen else { + throw terminalScopeError + } + guard modelFeed == nil else { + throw ConnectionModelFeedError.alreadyOpen + } + guard !replayWasTaintedByDirectConsumer else { + throw ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer + } + + let id = ConnectionModelFeedID() + let mailbox = ConnectionModelFeedMailbox(capacity: capacity) + 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, + targetSnapshotThrough: nil, + resetGeneration: resetGeneration + ) + + guard enqueueModelFeedRecord(.reset(resetGeneration)) else { + throw ConnectionModelFeedError.bufferOverflow(capacity: capacity) + } + if targetRegistry.currentMainPageTargetID != nil, + !publishModelTargetSnapshot() { + throw ConnectionModelFeedError.bufferOverflow(capacity: capacity) + } + return feed + } + + package func closeModelFeed(_ id: ConnectionModelFeedID) async throws { + guard let registration = modelFeed, + registration.id == id else { + return + } + guard case .open = state else { + return + } + modelFeed = nil + registration.mailbox.finish() + } + package func pageGeneration() throws -> WebInspectorPage.Generation { guard isOpen else { throw terminalScopeError @@ -296,6 +389,7 @@ package actor ConnectionCore { throw terminalScopeError } try requireAvailableTarget(for: route) + try claimDirectConsumer() let capacity = buffering.capacity let mailbox = WebInspectorEventMailbox(capacity: capacity) @@ -537,6 +631,12 @@ package actor ConnectionCore { 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 @@ -687,6 +787,102 @@ package actor ConnectionCore { } } + private func claimDirectConsumer() throws { + guard modelFeed == nil else { + throw WebInspectorProxyError.connectionInUse + } + replayWasTaintedByDirectConsumer = true + } + + private func claimLegacyPassiveConsumer() { + do { + try claimDirectConsumer() + } catch WebInspectorProxyError.connectionInUse { + preconditionFailure( + "Legacy passive event subscriptions cannot start while the connection is exclusively claimed by a model feed. This migration-only event surface must not be adapted around the feed claim." + ) + } catch { + preconditionFailure("Unexpected direct-consumer claim failure: \(error)") + } + } + + private func enqueueModelFeedRecord( + _ record: ConnectionModelFeedRecord + ) -> Bool { + guard let registration = modelFeed else { + return true + } + switch registration.mailbox.enqueue(record) { + case .enqueued: + return true + case .overflow: + handoffTermination( + .modelFeedFailure( + ConnectionModelFeedError.bufferOverflow( + capacity: registration.mailbox.capacity + ) + ) + ) + return false + 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 + modelFeed = registration + + guard registration.configuredDomains.isEmpty else { + return true + } + return enqueueModelFeedRecord( + .synchronizationComplete( + generation: currentPageGeneration, + through: through + ) + ) + } + + 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 cancelEventScopeActivation( _ id: WebInspectorProxyEventScopeID, key: ConnectionCapabilityKey @@ -1181,7 +1377,8 @@ package actor ConnectionCore { return true case .unsupported, .attachFailed, .closed, .staleIdentifier, .disconnected, .commandFailed, .protocolViolation, - .eventBufferOverflow, .transportFailure, .timeout: + .eventBufferOverflow, .connectionInUse, + .transportFailure, .timeout: return false } } @@ -1258,19 +1455,20 @@ package actor ConnectionCore { private func sendRoot(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { let commandID = allocateCommandID() let promise = ReplyPromise() - replyStore.insertRootReply(TransportSession.PendingReply.client( + try Task.checkCancellation() + let message = try TransportMessageParser.makeCommandString( + id: commandID, + method: command.method, + parametersData: command.parametersData + ) + let pending = try makeClientPendingReply( domain: command.domain, method: command.method, targetID: nil, promise: promise - ), commandID: commandID) + ) + 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 { @@ -1293,24 +1491,25 @@ package actor ConnectionCore { let outerCommandID = allocateCommandID() let key = TransportSession.ReplyKey(targetID: targetID, commandID: innerCommandID) let promise = ReplyPromise() - replyStore.insertTargetReply(TransportSession.PendingReply.client( + try Task.checkCancellation() + let message = try TransportMessageParser.makeCommandString( + id: innerCommandID, + method: command.method, + parametersData: command.parametersData + ) + let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( + id: outerCommandID, + targetIdentifier: targetID.rawValue, + message: message + ) + let pending = try makeClientPendingReply( domain: command.domain, method: command.method, targetID: targetID, promise: promise - ), key: key, rootWrapperID: outerCommandID) + ) + replyStore.insertTargetReply(pending, key: key, rootWrapperID: outerCommandID) do { - try Task.checkCancellation() - let message = try TransportMessageParser.makeCommandString( - id: innerCommandID, - method: command.method, - parametersData: command.parametersData - ) - let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( - id: outerCommandID, - targetIdentifier: targetID.rawValue, - message: message - ) try await backend.sendJSONString(wrapperMessage) try Task.checkCancellation() } catch { @@ -1325,6 +1524,24 @@ package actor ConnectionCore { ) } + private func makeClientPendingReply( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise + ) throws -> TransportSession.PendingReply { + // This is the single ownership boundary for every wire command whose + // reply belongs to a direct client. Capability and future feed-owned + // commands use distinct purposes and do not pass through this factory. + try claimDirectConsumer() + return TransportSession.PendingReply.client( + domain: domain, + method: method, + targetID: targetID, + promise: promise + ) + } + private func transportLocalResult( for command: ProtocolCommand, targetID: ProtocolTarget.ID @@ -1460,18 +1677,24 @@ package actor ConnectionCore { 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: [ResolvedStyleSheetAddedEvent] + let mutation: RootEventMutation do { - pendingStyleSheetAddedEvents = try await updateRegistryFromRootEvent( + mutation = try updateRegistryFromRootEvent( method: method, targetID: targetID, sourceTargetID: sourceTargetID, - paramsData: parsed.paramsData + paramsData: parsed.paramsData, + eventSequence: eventSequence.sequence ) } catch { guard isOpen else { @@ -1483,18 +1706,31 @@ package actor ConnectionCore { guard isOpen else { return } - await emit( - domain: ProtocolDomain(method: method), + // 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 mainPageTargetNotification = emit( + domain: domain, method: method, targetID: targetID, sourceTargetID: sourceTargetID, paramsData: parsed.paramsData, - destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget + destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget, + reservedEventSequence: eventSequence ) guard isOpen else { return } - await emitResolvedStyleSheetAddedEvents(pendingStyleSheetAddedEvents) + await completeMainPageTargetNotification(mainPageTargetNotification) + guard isOpen else { + return + } + await emitResolvedStyleSheetAddedEvents(mutation.pendingStyleSheetEvents) guard isOpen else { return } @@ -1556,13 +1792,14 @@ package actor ConnectionCore { sourceTargetID: targetID, paramsData: parsed.paramsData ) - await emit( + let mainPageTargetNotification = emit( domain: ProtocolDomain(method: method), method: method, targetID: emittedTargetID, sourceTargetID: targetID, paramsData: parsed.paramsData ) + await completeMainPageTargetNotification(mainPageTargetNotification) } private func resolve( @@ -1644,19 +1881,29 @@ package actor ConnectionCore { method: String, targetID: ProtocolTarget.ID?, sourceTargetID: ProtocolTarget.ID?, - paramsData: Data - ) async throws -> [ResolvedStyleSheetAddedEvent] { + paramsData: Data, + eventSequence: UInt64 + ) throws -> RootEventMutation { switch method { case "Target.targetCreated": let params = try TransportMessageParser.decode(TargetCreatedParams.self, from: paramsData) - return await applyTargetCreated(record(for: params.targetInfo)) + return applyTargetCreated( + record(for: params.targetInfo), + eventSequence: eventSequence + ) case "Target.targetDestroyed": let params = try TransportMessageParser.decode(TargetDestroyedParams.self, from: paramsData) - await applyTargetDestroyed(params.targetId) - return [] + return applyTargetDestroyed( + params.targetId, + eventSequence: eventSequence + ) case "Target.didCommitProvisionalTarget": let params = try TransportMessageParser.decode(TargetCommittedParams.self, from: paramsData) - return await applyTargetCommitted(oldTargetID: params.oldTargetId, newTargetID: params.newTargetId) + return applyTargetCommitted( + oldTargetID: params.oldTargetId, + newTargetID: params.newTargetId, + eventSequence: eventSequence + ) case "Runtime.executionContextCreated", "Runtime.executionContextDestroyed", "Runtime.executionContextsCleared": updateRegistryFromTargetEvent( method: method, @@ -1664,12 +1911,12 @@ package actor ConnectionCore { 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() } } @@ -1724,17 +1971,31 @@ package actor ConnectionCore { } } - private func applyTargetCreated(_ record: ProtocolTarget.Record) async -> [ResolvedStyleSheetAddedEvent] { + private func applyTargetCreated( + _ record: ProtocolTarget.Record, + eventSequence: UInt64 + ) -> RootEventMutation { let previousMainPageTargetID = targetRegistry.currentMainPageTargetID let resolution = targetRegistry.recordTargetCreated(record) - await currentPageBindingDidChange( + modelTargetMutationActionForTesting?() + let bindingEffects = prepareCurrentPageBindingChange( from: previousMainPageTargetID, to: targetRegistry.currentMainPageTargetID ) + if targetRegistry.isCurrentPageModelTarget(record), + let target = ModelTarget(record: record) { + publishModelTargetLifecycleEvent( + .targetCreated(target), + sequence: eventSequence + ) + } guard isOpen else { - return [] + return RootEventMutation() } - return resolvePendingStyleSheets(for: resolution) + return RootEventMutation( + pendingStyleSheetEvents: resolvePendingStyleSheets(for: resolution), + bindingEffects: bindingEffects + ) } private func record(for targetInfo: TargetInfoPayload) -> ProtocolTarget.Record { @@ -1759,30 +2020,50 @@ package actor ConnectionCore { 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) + modelTargetMutationActionForTesting?() let capabilityWaiters = physicalTargetDidDisappear(targetID) let pendingReplies = replyStore.removeTargetReplies(for: targetID) provisionalTargetMessageStore.removeTarget(targetID) styleSheetRouting.removeTarget(targetID) runtimeContextRegistry.removeTarget(targetID) - await currentPageBindingDidChange( + let bindingEffects = prepareCurrentPageBindingChange( from: previousMainPageTargetID, to: targetRegistry.currentMainPageTargetID ) - await resumePhysicalTargetDisappearanceWaiters(capabilityWaiters) - 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 + ) } + return RootEventMutation( + bindingEffects: bindingEffects, + physicalTargetWaiters: capabilityWaiters, + pendingRepliesToFail: pendingReplies, + missingTargetID: targetID + ) } private func applyTargetCommitted( oldTargetID: ProtocolTarget.ID, - newTargetID: ProtocolTarget.ID - ) async -> [ResolvedStyleSheetAddedEvent] { + newTargetID: ProtocolTarget.ID, + eventSequence: UInt64 + ) -> RootEventMutation { let previousMainPageTargetID = targetRegistry.currentMainPageTargetID let mutation = targetRegistry.commitTarget(oldTargetID: oldTargetID, newTargetID: newTargetID) + modelTargetMutationActionForTesting?() var capabilityWaiters = PhysicalTargetDisappearanceWaiters() var stalePendingReplies: [TransportSession.PendingReply] = [] @@ -1795,29 +2076,39 @@ package actor ConnectionCore { runtimeContextRegistry.retarget(oldTargetID: oldTargetID, newTargetID: newTargetID) } - await currentPageBindingDidChange( + let bindingEffects = prepareCurrentPageBindingChange( from: previousMainPageTargetID, to: targetRegistry.currentMainPageTargetID ) - await resumePhysicalTargetDisappearanceWaiters(capabilityWaiters) - for pending in stalePendingReplies { - await pending.promise.fulfill( - .failure(TransportSession.Error.missingTarget(mutation.committedOldTargetID)) + 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 ) } - guard isOpen else { - return [] - } - return resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) + return RootEventMutation() + } + return RootEventMutation( + pendingStyleSheetEvents: resolvePendingStyleSheets(for: mutation.resolvedFrameTarget), + bindingEffects: bindingEffects, + physicalTargetWaiters: capabilityWaiters, + pendingRepliesToFail: stalePendingReplies, + missingTargetID: mutation.committedOldTargetID + ) } - private func currentPageBindingDidChange( + private func prepareCurrentPageBindingChange( from oldTargetID: ProtocolTarget.ID?, to newTargetID: ProtocolTarget.ID? - ) async { + ) -> CurrentPageBindingChangeEffects { guard oldTargetID != newTargetID else { - return + return CurrentPageBindingChangeEffects() } if oldTargetID == nil, @@ -1830,13 +2121,14 @@ package actor ConnectionCore { // transition. currentPageBindingGapIsOpen = false let keys = capabilities.states.keys.filter { $0.route == .currentPage } - for key in keys { - await reconcileCapability(for: key) - guard isOpen else { - return - } + publishModelBindingChange(hasCurrentBinding: true) + guard isOpen else { + return CurrentPageBindingChangeEffects() } - return + return CurrentPageBindingChangeEffects( + capabilityKeysToReconcile: keys, + releaseWaiters: [] + ) } currentPageBindingGapIsOpen = oldTargetID != nil && newTargetID == nil @@ -1846,6 +2138,10 @@ package actor ConnectionCore { eventScopes.publishReset(currentPageGeneration) { sink in sink.route == .currentPage } + publishModelBindingChange(hasCurrentBinding: newTargetID != nil) + guard isOpen else { + return CurrentPageBindingChangeEffects() + } let keys = capabilities.states.keys.filter { $0.route == .currentPage } var releaseWaiters: [ReplyPromise] = [] @@ -1859,19 +2155,67 @@ package actor ConnectionCore { capabilities.states[key] = capability } - if newTargetID != nil { - for key in keys { - await reconcileCapability(for: key) - if !isOpen { - break - } + return CurrentPageBindingChangeEffects( + capabilityKeysToReconcile: newTargetID == nil ? [] : keys, + releaseWaiters: releaseWaiters + ) + } + + private func completeCurrentPageBindingChange( + _ effects: CurrentPageBindingChangeEffects + ) async { + for key in effects.capabilityKeysToReconcile { + await reconcileCapability(for: key) + if !isOpen { + break } } - for waiter in releaseWaiters { + for waiter in effects.releaseWaiters { await waiter.fulfill(.success(())) } } + private func completeRootEventMutation( + _ mutation: RootEventMutation + ) async { + await completeCurrentPageBindingChange(mutation.bindingEffects) + await resumePhysicalTargetDisappearanceWaiters( + mutation.physicalTargetWaiters + ) + guard !mutation.pendingRepliesToFail.isEmpty else { + return + } + guard let missingTargetID = mutation.missingTargetID else { + preconditionFailure("Pending target replies lost their missing-target owner.") + } + for pending in mutation.pendingRepliesToFail { + await pending.promise.fulfill( + .failure(TransportSession.Error.missingTarget(missingTargetID)) + ) + } + } + + private func publishModelBindingChange(hasCurrentBinding: Bool) { + guard var registration = modelFeed else { + return + } + if registration.targetSnapshotThrough != nil { + registration.targetSnapshotThrough = nil + registration.resetGeneration = currentPageGeneration + 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 { @@ -2073,12 +2417,13 @@ package actor ConnectionCore { guard isOpen else { return } - await emit( + let mainPageTargetNotification = emit( domain: .css, method: "CSS.styleSheetAdded", targetID: event.targetID, paramsData: event.paramsData ) + await completeMainPageTargetNotification(mainPageTargetNotification) guard isOpen else { return } @@ -2103,12 +2448,27 @@ package actor ConnectionCore { targetID: ProtocolTarget.ID?, sourceTargetID: ProtocolTarget.ID? = nil, paramsData: Data, - destroyedCurrentMainPageTarget: Bool = false - ) async { + destroyedCurrentMainPageTarget: Bool = false, + reservedEventSequence: TransportEventSequenceSnapshot? = nil + ) -> MainPageTargetNotification? { guard isOpen else { - return + return nil + } + 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 eventSequence = eventSequences.recordEvent(domain: domain) let envelope = ProtocolEvent( sequence: eventSequence.sequence, domain: domain, @@ -2194,16 +2554,156 @@ package actor ConnectionCore { } if let terminalViolation { handoffTermination(.protocolViolation(terminalViolation)) - return + return nil } } + do { + try publishConfiguredModelEvent( + envelope, + in: snapshot() + ) + } catch { + handoffTermination( + .protocolViolation("Failed to decode \(method): \(error)") + ) + return nil + } + guard isOpen else { + return nil + } for continuation in eventSubscribers.continuations(for: domain) { continuation.yield(envelope) } for continuation in eventSubscribers.orderedContinuations { continuation.yield(envelope) } - await notifyMainPageTargetWaitersIfNeeded(receivedSequence: eventSequence.sequence) + return prepareMainPageTargetNotificationIfNeeded( + receivedSequence: eventSequence.sequence + ) + } + + 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 + } + + let configuredDomain = modelDomain(for: event.domain) + guard event.domain == .page + || configuredDomain.map(registration.configuredDomains.contains) == true 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 .inspector: + payload = nil + } + + 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 + } } private func webInspectorEventDomain(for domain: ProtocolDomain) -> WebInspectorProxyEventDomain? { @@ -2229,18 +2729,29 @@ package actor ConnectionCore { } } - 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)) + return MainPageTargetNotification(waiters: waiters, result: result) + } + + private func completeMainPageTargetNotification( + _ notification: MainPageTargetNotification? + ) async { + guard let notification else { + return + } + for waiter in notification.waiters { + await waiter.fulfill(.success(notification.result)) } } @@ -2302,6 +2813,8 @@ package actor ConnectionCore { .transportFailure(message) case let .protocolViolation(message): .transportFailure(message) + case let .modelFeedFailure(error): + .transportFailure(String(describing: error)) } } @@ -2313,6 +2826,8 @@ package actor ConnectionCore { WebInspectorProxyError.transportFailure(message) case let .protocolViolation(message): WebInspectorProxyError.protocolViolation(message) + case let .modelFeedFailure(error): + WebInspectorProxyError.transportFailure(String(describing: error)) } } @@ -2368,6 +2883,7 @@ package actor ConnectionCore { let scopeError: WebInspectorProxyError? = cause == .explicitClose ? nil : terminalScopeError if let scopeError { eventScopes.finishSubscribers(with: scopeError) + modelFeed?.mailbox.poison(throwing: scopeError) } var activationWaiters: [ReplyPromise] = [] @@ -2432,7 +2948,9 @@ package actor ConnectionCore { if cause == .explicitClose { eventScopes.finishSubscribers(with: nil) eventSubscribers.finishAndRemoveAll() + modelFeed?.mailbox.finish() } + modelFeed = nil resumeCloseWaiters(with: terminalResult(for: cause)) terminalTask = nil } @@ -2445,6 +2963,8 @@ package actor ConnectionCore { .failure(WebInspectorProxyError.disconnected(message)) case let .protocolViolation(message): .failure(WebInspectorProxyError.protocolViolation(message)) + case let .modelFeedFailure(error): + .failure(WebInspectorProxyError.transportFailure(String(describing: error))) } } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift b/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift index 1c3a3d55..8ccb7413 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift @@ -158,6 +158,83 @@ struct TransportTargetRegistry: Sendable { ) } + 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 (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 + } + + 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 + } + + 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( for record: ProtocolTarget.Record ) -> TransportFrameTargetResolution? { diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index 8d85f5ea..7c8bdbcb 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -224,6 +224,16 @@ public actor WebInspectorProxy { await core.close() } + package func openModelFeed( + configuredDomains: Set, + capacity: Int = 256 + ) async throws -> ConnectionModelFeed { + try await core.openModelFeed( + configuredDomains: configuredDomains, + capacity: capacity + ) + } + /// Suspends until ``close()`` has finished. /// /// If the proxy is already closed, this method returns immediately. If the diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift index 5a18065e..5352feba 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift @@ -32,6 +32,9 @@ public enum WebInspectorProxyError: Error, Sendable, Equatable { /// 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) diff --git a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index c026f751..9378b6d6 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -55,7 +55,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { } /// The kind of backend target represented by a ``WebInspectorTarget``. - public enum Kind: Sendable { + public enum Kind: Equatable, Sendable { /// A top-level page target. case page diff --git a/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift b/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift index b6b877af..0f6c8275 100644 --- a/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift +++ b/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift @@ -349,7 +349,7 @@ package final class NetworkBodyViewController: UIViewController, NetworkBodyPrev private func localizedDescription(for error: WebInspectorProxyError) -> String { switch error { - case .closed, .pageUnavailable, .staleIdentifier: + case .closed, .pageUnavailable, .staleIdentifier, .connectionInUse: String(localized: "network.body.fetch.error.unavailable", bundle: WebInspectorUILocalization.bundle) case .unsupported(let messages): messages.joined(separator: "\n") diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift new file mode 100644 index 00000000..b98b9676 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -0,0 +1,867 @@ +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 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: [], capacity: 8) + 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: [], capacity: 8) + 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 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: [.network], capacity: 8) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + let initialSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + + 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")) + + try await feed.close() + 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: [], capacity: 8) + 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: [], capacity: 8) + 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: [], capacity: 8) + 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: [], capacity: 8) + } + + try await feed.close() + let replacementFeed = try await core.openModelFeed(configuredDomains: [], capacity: 8) + 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: [], capacity: 8) + } + + 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: [], capacity: 8) + } + await core.close() +} + +@Test +func modelFeedOverflowPoisonsFeedAndConnectionWithoutSilentDrop() 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: [], capacity: 3) + + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + + var iterator = feed.records.makeAsyncIterator() + await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 3)) { + try await iterator.next() + } + #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 3))) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } +} + +@Test +func modelFeedAndDirectConsumersClaimConnectionExclusivelyInBothOrders() async throws { + let directFirstCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await directFirstCore.events(for: .network) + await #expect(throws: ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer) { + try await directFirstCore.openModelFeed(configuredDomains: [], capacity: 8) + } + 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: [], capacity: 8) + await #expect(throws: ConnectionModelFeedError.alreadyOpen) { + try await feedFirstCore.openModelFeed(configuredDomains: [], capacity: 8) + } + 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() +} + +#if os(macOS) +@Test +func legacyPassiveStreamAfterModelFeedIsAProgrammerError() async { + await #expect(processExitsWith: .failure) { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = try await core.openModelFeed(configuredDomains: [], capacity: 8) + _ = await core.events(for: .network) + } +} +#endif + +@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: [], capacity: 8) + 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: [], capacity: 8) + 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: [], capacity: 8) + 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: [], + capacity: 8 + ) + 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: [], capacity: 8) + } + 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: [], capacity: 8) + 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 orderedEvents = await core.orderedEvents() + let eventObservationTask = Task { + var iterator = orderedEvents.makeAsyncIterator() + let event = await iterator.next() + let purposes = await core.pendingReplyPurposes() + return (event, purposes) + } + 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 eventObservation = await eventObservationTask.value + let replacementEvent = try #require(eventObservation.0) + let pendingPurposes = eventObservation.1 + + #expect(replacement.targetID == ProtocolTarget.ID("page-new")) + #expect(replacementEvent.method == "Target.targetCreated") + #expect(replacementEvent.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 +} + +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 ModelFeedSynchronizationRecord { + let generation: WebInspectorPage.Generation + 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 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 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 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() + } +} From 7d6a9421a5bb7fa1b56c6047a15a85f4e6292208 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:33:16 +0900 Subject: [PATCH 20/81] fix(ui): break presentation content ownership cycle --- .../Containers/CompactTabBarController.swift | 13 +- .../RegularTabContentViewController.swift | 12 +- .../Containers/WebInspectorSession.swift | 95 +------ .../WebInspectorViewController.swift | 30 ++- Sources/WebInspectorUI/DOMTabController.swift | 62 +++-- .../WebInspectorUI/NetworkTabController.swift | 40 +-- Sources/WebInspectorUI/README.md | 5 +- .../Tabs/BuiltInTabControllers.swift | 75 +----- .../Tabs/PresentationContentStore.swift | 75 ++++++ Sources/WebInspectorUI/Tabs/TabModels.swift | 30 --- .../WebInspectorUI/Tabs/WebInspectorTab.swift | 5 +- .../ParentContainerTests.swift | 254 ++++++++++++++---- 12 files changed, 415 insertions(+), 281 deletions(-) create mode 100644 Sources/WebInspectorUI/Tabs/PresentationContentStore.swift diff --git a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift index b6524c76..e28ae98d 100644 --- a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift +++ b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift @@ -6,14 +6,19 @@ 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 @@ -79,6 +84,7 @@ package final class CompactTabBarController: UITabBarController, UITabBarControl let displayItems = interface.displayItems(for: .compact) let selectedDisplayItem = interface.resolvedSelection(for: .compact) let contentRevision = interface.contextBoundContentRevision + contentStore.prepare(for: contentRevision) let shouldRebuildContent = renderedContentRevision.map { $0 != contentRevision } ?? false renderSelectionFromInterface { if shouldRebuildContent { @@ -133,6 +139,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 +151,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..cae17224 100644 --- a/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift +++ b/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift @@ -6,6 +6,7 @@ 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? @@ -28,8 +29,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 @@ -89,6 +94,7 @@ package final class RegularTabContentViewController: UINavigationController { let displayItems = interface.displayItems(for: .regular) let selectedDisplayItem = interface.resolvedSelection(for: .regular) let contentRevision = interface.contextBoundContentRevision + contentStore.prepare(for: contentRevision) let shouldRebuildContent = renderedContentRevision.map { $0 != contentRevision } ?? false let activeItemIDs = Set(displayItems.map(\.id)) if let displayedDisplayItemID, @@ -152,8 +158,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..5377b644 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -4,12 +4,11 @@ import UIKit import WebKit import WebInspectorDataKit 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, the current DataKit context, and +/// page-derived presentation preferences. @MainActor @Observable public final class WebInspectorSession { @@ -56,7 +55,6 @@ public final class WebInspectorSession { isolated deinit { stopPageUserInterfaceStyleObservation() - interface.removeContentCache() } package var context: WebInspectorContext { @@ -152,19 +150,8 @@ public final class WebInspectorSession { await stopContainer(replaceContextWithDetached: true) } - package func retireRootPresentation(detach: Bool) async { - guard detach else { - interface.removeContentCache() - await suspendBackendInteractionForPresentationEnd() - return - } - await detachAndReplaceContext() - } - - /// 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 { + /// Disables transient page interaction without tearing down the connection. + package func suspendBackendInteraction() async { // Bind the context once: a concurrent attach can swap dataContext // across the awaits below, and this retirement must not touch the // replacement context. @@ -217,11 +204,11 @@ public final class WebInspectorSession { package func installDataContext(_ context: WebInspectorContext) { dataContext = context - removeContextBoundContent() + interface.contextDidChange() } private func stopContainer(replaceContextWithDetached: Bool) async { - removeContextBoundContent() + interface.contextDidChange() if let container { self.container = nil await container.close() @@ -233,10 +220,6 @@ public final class WebInspectorSession { } } - private func removeContextBoundContent() { - interface.removeContextBoundContent() - } - private static func makeDetachedDataContext() -> WebInspectorContext { WebInspectorContext.detached(isolation: MainActor.shared) } @@ -253,12 +236,10 @@ 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,58 +293,13 @@ 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 + package func contextDidChange() { + precondition( + contextBoundContentRevision < Int.max, + "A presentation content revision must not overflow." + ) + contextBoundContentRevision += 1 } - #endif package var selectedTab: WebInspectorTab? { guard let selectedItemID else { @@ -403,11 +339,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.swift b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift index 2fd12f66..0080eb89 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift @@ -134,6 +134,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 +171,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 +287,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 +306,12 @@ public final class WebInspectorViewController: UIViewController { guard presentationLifecycleCoordinator.isCurrentPresentation(generation) else { return } - await session.retireRootPresentation(detach: automaticallyDetachesOnDismiss) + presentationContentStore.clear() + if automaticallyDetachesOnDismiss { + await session.detach() + } else { + await session.suspendBackendInteraction() + } } } } @@ -360,9 +372,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 +416,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/DOMTabController.swift b/Sources/WebInspectorUI/DOMTabController.swift index a78327e1..9a482e74 100644 --- a/Sources/WebInspectorUI/DOMTabController.swift +++ b/Sources/WebInspectorUI/DOMTabController.swift @@ -41,59 +41,67 @@ 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), + rootViewController: cachedTreeViewController( + session: session, + contentStore: contentStore + ), context: session.context ) 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), + treeViewController: cachedTreeViewController( + session: session, + contentStore: contentStore + ), + elementViewController: cachedElementViewController( + session: session, + contentStore: contentStore + ), context: session.context ) ) } } - private func cachedTreeViewController(session: WebInspectorSession) -> DOMTreeViewController { - session.interface.viewController(for: contentKey(ContentID.tree)) { + private func cachedTreeViewController( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) -> DOMTreeViewController { + contentStore.viewController( + for: contentKey(ContentID.tree), + contextEpoch: session.interface.contextBoundContentRevision + ) { DOMTreeViewController(context: session.context) } } - private func cachedElementViewController(session: WebInspectorSession) -> DOMElementViewController { - session.interface.viewController(for: contentKey(ContentID.element)) { + private func cachedElementViewController( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) -> DOMElementViewController { + contentStore.viewController( + for: contentKey(ContentID.element), + contextEpoch: session.interface.contextBoundContentRevision + ) { DOMElementViewController(context: session.context) } } diff --git a/Sources/WebInspectorUI/NetworkTabController.swift b/Sources/WebInspectorUI/NetworkTabController.swift index 07c69325..b6b07b00 100644 --- a/Sources/WebInspectorUI/NetworkTabController.swift +++ b/Sources/WebInspectorUI/NetworkTabController.swift @@ -17,24 +17,26 @@ 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) + let model = contentStore.networkPanelModel( + for: session.context, + contextEpoch: session.interface.contextBoundContentRevision + ) + let listViewController = cachedListViewController( + session: session, + contentStore: contentStore, + model: model + ) + let detailViewController = cachedDetailViewController( + session: session, + contentStore: contentStore, + model: model + ) switch layout { case .compact: @@ -56,18 +58,26 @@ 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), + contextEpoch: session.interface.contextBoundContentRevision + ) { 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), + contextEpoch: session.interface.contextBoundContentRevision + ) { NetworkDetailViewController( model: model, makeBodyViewController: NetworkBodyPreviewFactory.make(scrollEdgeSink:) diff --git a/Sources/WebInspectorUI/README.md b/Sources/WebInspectorUI/README.md index 343fb339..d8834109 100644 --- a/Sources/WebInspectorUI/README.md +++ b/Sources/WebInspectorUI/README.md @@ -16,6 +16,8 @@ package for symbol-level details. wraps `WebInspectorContainer` / `WebInspectorContext` 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 presentation model; 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 @@ -30,7 +32,8 @@ regular width uses split presentation. `WebInspectorSession` owns the UI-facing session lifecycle and exposes the current `WebInspectorContext`. DOM and Network controllers observe DataKit -models and submit DataKit commands. +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..2dc17aa7 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,12 +100,15 @@ 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)) { + let viewController = contentStore.viewController( + for: customContentKey(for: tab), + contextEpoch: session.interface.contextBoundContentRevision + ) { content.makeViewController(session) } switch hostLayout { @@ -145,44 +127,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..ab219bea --- /dev/null +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -0,0 +1,75 @@ +#if canImport(UIKit) +import UIKit +import WebInspectorDataKit +import WebInspectorUINetwork + +/// Owns view controllers and models whose lifetime is bounded by one root +/// inspector presentation. +@MainActor +package final class PresentationContentStore { + private let contentCache = WebInspectorTab.ContentCache() + private var networkPanelModel: NetworkPanelModel? + private var contextEpoch: Int? + + package init() {} + + isolated deinit { + clearResources() + } + + package func viewController( + for key: WebInspectorTab.ContentKey, + contextEpoch: Int, + make: () -> Content + ) -> Content { + prepare(for: contextEpoch) + return contentCache.viewController(for: key, make: make) + } + + package func networkPanelModel( + for context: WebInspectorContext, + contextEpoch: Int + ) -> NetworkPanelModel { + prepare(for: contextEpoch) + if let networkPanelModel { + precondition( + networkPanelModel.context === context, + "A presentation context must change together with its context epoch." + ) + return networkPanelModel + } + + let model = NetworkPanelModel(context: context) + networkPanelModel = model + return model + } + + package func prepare(for contextEpoch: Int) { + guard self.contextEpoch != contextEpoch else { + return + } + clearResources() + self.contextEpoch = contextEpoch + } + + package func clear() { + clearResources() + contextEpoch = nil + } + + private func clearResources() { + networkPanelModel = nil + contentCache.removeAll() + } + + #if DEBUG + package var contentCountForTesting: Int { + contentCache.countForTesting + } + + package var contentCacheForTesting: WebInspectorTab.ContentCache { + contentCache + } + #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..6b080ec6 100644 --- a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift +++ b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift @@ -83,8 +83,9 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { /// 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. WebInspectorKit caches the returned + /// controller for the tab ID and reuses it across that controller's compact + /// and regular hosts. public init( id: ID, title: String, diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index d3d032a5..b13bf076 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -216,19 +216,24 @@ struct ParentContainerTests { } @Test - func attachClearsCachedTabContentForPreviousContext() async throws { + func attachAdvancesContextEpochAndEvictsRootPresentationContent() async throws { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting + let initialContextRevision = session.interface.contextBoundContentRevision _ = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) _ = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .regular ) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) var installedContext: WebInspectorContext? try await session.attachForTesting { @@ -239,56 +244,67 @@ struct ParentContainerTests { let context = try #require(installedContext) #expect(session.context === context) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(session.interface.contextBoundContentRevision > initialContextRevision) + contentStore.prepare(for: session.interface.contextBoundContentRevision) + #expect(contentStore.contentCountForTesting == 0) } @Test - func installingDataContextClearsCachedTabContent() { + func installingDataContextAdvancesContextEpochAndEvictsRootPresentationContent() { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting + let initialContextRevision = session.interface.contextBoundContentRevision _ = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) _ = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .regular ) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) let context = makeContext() session.installDataContext(context) #expect(session.context === context) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(session.interface.contextBoundContentRevision > initialContextRevision) + contentStore.prepare(for: session.interface.contextBoundContentRevision) + #expect(contentStore.contentCountForTesting == 0) } @Test - func contentCacheEvictsEntriesFromPreviousEpoch() { - let cache = WebInspectorTab.ContentCache() + func presentationContentStoreEvictsEntriesFromPreviousContextEpoch() { + let contentStore = PresentationContentStore() let key = WebInspectorTab.ContentKey(tabID: "epoch-tab", contentID: "root") - let first = cache.viewController(for: key, epoch: 0) { UIViewController() } - #expect(cache.viewController(for: key, epoch: 0) { UIViewController() } === first) + let first = contentStore.viewController(for: key, contextEpoch: 0) { UIViewController() } + #expect(contentStore.viewController(for: key, contextEpoch: 0) { UIViewController() } === first) - let second = cache.viewController(for: key, epoch: 1) { UIViewController() } + let second = contentStore.viewController(for: key, contextEpoch: 1) { UIViewController() } #expect(second !== first) - #expect(cache.viewController(for: key, epoch: 1) { UIViewController() } === second) - #expect(cache.countForTesting == 1) + #expect(contentStore.viewController(for: key, contextEpoch: 1) { UIViewController() } === second) + #expect(contentStore.contentCountForTesting == 1) } @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,7 +315,7 @@ struct ParentContainerTests { #expect(await viewController.waitForRootPresentationRetirementTaskCompletionForTesting(after: retirementBaseline)) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) } @Test @@ -404,7 +420,11 @@ struct ParentContainerTests { @Test func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() throws { let session = WebInspectorViewControllerPreviewFixtures.makeSession() - let model = session.interface.networkPanelModel(for: session.context) + let viewController = WebInspectorViewController(session: session) + let model = viewController.presentationContentStoreForTesting.networkPanelModel( + for: session.context, + contextEpoch: session.interface.contextBoundContentRevision + ) #expect(session.context.rootNode?.nodeName == "#document") #expect(model.displayRequests.count >= 2) @@ -440,6 +460,7 @@ struct ParentContainerTests { return customViewController } let session = WebInspectorSession(tabs: [.dom, customTab, .network]) + let contentStore = PresentationContentStore() let projection = WebInspectorTab.DisplayProjection() #expect( @@ -469,14 +490,14 @@ struct ParentContainerTests { let compactContent = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) let regularContent = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, - hostLayout: .regular, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .regular ) regularContent.loadViewIfNeeded() #expect(regularContent !== customViewController) @@ -485,8 +506,8 @@ struct ParentContainerTests { let reparentedContent = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) #expect(compactContent === customViewController) @@ -496,6 +517,83 @@ struct ParentContainerTests { #expect(factoryCallCount == 1) } + @Test + func rootContentStoreDoesNotCloseCycleThroughSessionRetainingCustomController() throws { + weak var retainedRoot: WebInspectorViewController? + weak var retainedStore: PresentationContentStore? + weak var retainedCache: WebInspectorTab.ContentCache? + weak var retainedSession: WebInspectorSession? + weak var retainedContent: UIViewController? + weak var retainedNetworkModel: NetworkPanelModel? + + autoreleasepool { + 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 networkModel = contentStore.networkPanelModel( + for: session.context, + contextEpoch: session.interface.contextBoundContentRevision + ) + let content = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) + let retainingContent = try? #require(content as? SessionRetainingViewController) + + #expect(retainingContent?.session === session) + retainedRoot = root + retainedStore = contentStore + retainedCache = contentStore.contentCacheForTesting + retainedSession = session + retainedContent = content + retainedNetworkModel = networkModel + } + + #expect(retainedRoot == nil) + #expect(retainedStore == nil) + #expect(retainedCache == nil) + #expect(retainedSession == nil) + #expect(retainedContent == nil) + #expect(retainedNetworkModel == nil) + } + + @Test + func presentationContentStoreDeinitDetachesExternallyRetainedCustomController() 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 regularContent = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: try #require(contentStore), + hostLayout: .regular + ) + regularContent.loadViewIfNeeded() + #expect(customViewController.parent === regularContent) + + contentStore = nil + + #expect(retainedStore == nil) + #expect(customViewController.parent == nil) + } + @Test func customTabDisplayItemDoesNotCollideWithInternalDOMElementIdentifier() throws { let customViewController = UIViewController() @@ -507,6 +605,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) @@ -534,8 +633,8 @@ struct ParentContainerTests { let customContent = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) #expect(customContent === customViewController) } @@ -552,7 +651,10 @@ struct ParentContainerTests { customNavigationController } let session = WebInspectorSession(tabs: [customTab]) - let host = RegularTabContentViewController(session: session) + let host = RegularTabContentViewController( + session: session, + contentStore: PresentationContentStore() + ) host.loadViewIfNeeded() @@ -573,8 +675,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 +690,10 @@ struct ParentContainerTests { ] ) - let regularHost = RegularTabContentViewController(session: session) + let regularHost = RegularTabContentViewController( + session: session, + contentStore: contentStore + ) regularHost.loadViewIfNeeded() let segmentedControl = regularHost.segmentedControlForTesting @@ -626,14 +735,16 @@ 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) + #expect(contentStore.contentCountForTesting > 0) let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision viewController.finishRootPresentationLifecycleForTesting() @@ -641,7 +752,7 @@ struct ParentContainerTests { viewController.finishRootPresentationLifecycleForTesting() #expect(session.detachCountForTesting == 1) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(contentStore.contentCountForTesting == 0) #expect(session.interface.contextBoundContentRevision > contentRevisionBeforeRetirement) } @@ -649,56 +760,58 @@ struct ParentContainerTests { 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 _ = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(tab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) 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.contentCountForTesting > 0) let coveringViewController = UIViewController() navigationController.pushViewController(coveringViewController, animated: false) #expect(navigationController.topViewController === coveringViewController) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) window.rootViewController = UIViewController() window.layoutIfNeeded() #expect(navigationController.view.window == nil) #expect(await waitUntilDetachCount(1, in: session)) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(contentStore.contentCountForTesting == 0) } @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 _ = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(tab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) let window = showInWindow(viewController) defer { window.isHidden = true } #expect(viewController.view.window === window) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) window.rootViewController = UIViewController() window.layoutIfNeeded() #expect(viewController.view.window == nil) #expect(await waitUntilDetachCount(1, in: session)) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(contentStore.contentCountForTesting == 0) } @Test @@ -723,20 +836,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,20 +878,23 @@ 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) + #expect(contentStore.contentCountForTesting > 0) let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting @@ -784,7 +902,7 @@ struct ParentContainerTests { #expect(await viewController.waitForRootPresentationRetirementTaskCompletionForTesting(after: retirementBaseline)) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(contentStore.contentCountForTesting == 0) #expect(session.interface.contextBoundContentRevision == contentRevisionBeforeRetirement) } @@ -813,7 +931,10 @@ struct ParentContainerTests { @Test func compactHostDisplaysDOMElementAndNetworkTabs() { let session = WebInspectorSession() - let host = CompactTabBarController(session: session) + let host = CompactTabBarController( + session: session, + contentStore: PresentationContentStore() + ) #expect( host.displayedTabIdentifiersForTesting @@ -824,10 +945,12 @@ struct ParentContainerTests { @Test func compactFactoryUsesDomainNavigationControllers() 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,6 +959,7 @@ 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) @@ -844,6 +968,7 @@ struct ParentContainerTests { let networkViewController = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .compact ) let networkNavigationController = try #require(networkViewController as? NetworkCompactNavigationController) @@ -853,7 +978,10 @@ struct ParentContainerTests { @Test func regularHostWrapsDomainSplitControllersBeforeInstallingInNavigationStack() throws { let session = WebInspectorSession() - let host = RegularTabContentViewController(session: session) + let host = RegularTabContentViewController( + session: session, + contentStore: PresentationContentStore() + ) host.loadViewIfNeeded() @@ -873,9 +1001,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 +1016,7 @@ struct ParentContainerTests { let regularRoot = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .regular ) regularRoot.loadViewIfNeeded() @@ -908,17 +1039,22 @@ 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 model = contentStore.networkPanelModel( + for: context, + contextEpoch: session.interface.contextBoundContentRevision + ) let compactNavigationController = try #require( WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .compact ) as? NetworkCompactNavigationController ) @@ -935,6 +1071,7 @@ struct ParentContainerTests { let regularRoot = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .regular ) regularRoot.loadViewIfNeeded() @@ -1133,6 +1270,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 From 7314fa7a3e56dc2ef46be1384523aa23cb71f281 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:38:47 +0900 Subject: [PATCH 21/81] refactor(data): move Network state to its owner --- Sources/WebInspectorDataKit/Fetching.swift | 11 + .../NetworkRequestStore.swift | 787 ++++++++++++++++++ .../WebInspectorContext.swift | 618 ++------------ .../NetworkRequestStoreTests.swift | 117 +++ 4 files changed, 979 insertions(+), 554 deletions(-) create mode 100644 Sources/WebInspectorDataKit/NetworkRequestStore.swift create mode 100644 Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 771b85f8..0687979d 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -670,6 +670,17 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { guard delta.sequence > networkIndexSequence else { return } + if delta.snapshot == state.snapshot { + publish( + items: state.items, + sections: state.sections, + snapshot: delta.snapshot, + transaction: delta.transaction, + updatedItemIDs: delta.reconfigureItemIDs + ) + networkIndexSequence = delta.sequence + return + } let items = delta.snapshot.itemIDs.map { id in guard let request = lookup(id) else { preconditionFailure("A NetworkRequestIndex snapshot referenced an unregistered request.") diff --git a/Sources/WebInspectorDataKit/NetworkRequestStore.swift b/Sources/WebInspectorDataKit/NetworkRequestStore.swift new file mode 100644 index 00000000..a3919265 --- /dev/null +++ b/Sources/WebInspectorDataKit/NetworkRequestStore.swift @@ -0,0 +1,787 @@ +import Foundation +import WebInspectorProxyKit + +/// Owns Network request identity, order, query projection, and publication. +/// +/// `WebInspectorContext` remains the attachment and transport coordinator. The +/// store is confined by the actor supplied by each caller and never retains an +/// actor token or starts transport work. +package final class NetworkRequestStore { +#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 fetchedResults: [WeakWebInspectorFetchedResults] +#if DEBUG + private var performanceCounters: PerformanceCounters +#endif + + package init() { + collectionState = NetworkRequestCollectionState() + requestsByID = [:] + orderedRequestIDs = [] + orderIndicesByID = [:] + clearedRequestIDs = [] + queryIndex = NetworkRequestIndex() + queryIndexSequence = 0 + queryIndexNeedsRebuild = false + fetchedResults = [] +#if DEBUG + performanceCounters = PerformanceCounters() +#endif + } + +#if DEBUG + package var performanceCountersForTesting: PerformanceCounters { + performanceCounters + } + + package func resetPerformanceCountersForTesting( + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + performanceCounters = PerformanceCounters() + } +#endif + + package func request( + for id: NetworkRequest.ID, + isolation: isolated (any Actor) = #isolation + ) -> NetworkRequest? { + _ = isolation + return requestsByID[id] + } + + package func request( + forProxyID id: Network.Request.ID, + isolation: isolated (any Actor) = #isolation + ) -> NetworkRequest? { + request(for: NetworkRequest.ID(id), isolation: isolation) + } + + package func finishResponseBodyFetch( + _ result: Result, + for request: NetworkRequest, + expectedBody: NetworkBody, + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + guard requestsByID[request.id] === request else { + return + } + request.finishResponseBodyFetch(result: result, expectedBody: expectedBody) + } + + package func register( + _ results: WebInspectorFetchedResults, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) { + let plan = NetworkRequestQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) + results.setNetworkItems( + currentRequests(isolation: isolation), + plan: plan, + indexSequence: queryIndexSequence, + lookup: { id in self.requestsByID[id] } + ) + fetchedResults.append(WeakWebInspectorFetchedResults(results)) + } + + package func updateFetchDescriptor( + _ descriptor: WebInspectorFetchDescriptor, + for results: WebInspectorFetchedResults, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) { + let plan = NetworkRequestQueryPlan(descriptor: descriptor, context: modelContext) + results.applyNetworkFetchDescriptor( + descriptor, + plan: plan, + requests: currentRequests(isolation: isolation), + indexSequence: queryIndexSequence, + lookup: { id in self.requestsByID[id] } + ) + } + + /// Clears requests while remembering their protocol identities so late + /// terminal events from the cleared loads remain ignorable. + package func clear(isolation: isolated (any Actor) = #isolation) { + clearedRequestIDs.formUnion(requestsByID.keys) + removeAllRequests(isolation: isolation) + } + + /// Resets state for a new attachment. Protocol identities may be reused by + /// the new target, so the late-event suppression set is also discarded. + package func resetForNewAttachment(isolation: isolated (any Actor) = #isolation) { + clearedRequestIDs = [] + removeAllRequests(isolation: isolation) + } + + @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: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> NetworkRequest.ID { + _ = 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: modelContext + ) + requestsByID[id] = request + appendRequestID(id, isolation: isolation) + 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 + pruneFetchedResults(isolation: isolation) + if inserted { + collectionState.didInsertRequest() + for registration in fetchedResults { + registration.value?.insertNetworkRequest( + request, + lookup: { id in self.requestsByID[id] } + ) + } + } else { + for registration in fetchedResults { + 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 + ) { + _ = 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, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) 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, + isolation: isolation + ) + case let .responseReceived(id, response, resourceType, timestamp): + await applyResponseReceived( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext, + isolation: isolation + ) + case let .dataReceived(id, dataLength, encodedDataLength, timestamp): + guard let request = request(for: id, method: "dataReceived", isolation: isolation) else { + return + } + request.applyDataReceived( + dataLength: dataLength, + encodedDataLength: encodedDataLength, + timestamp: timestamp + ) + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + case let .loadingFinished(id, timestamp, sourceMapURL, metrics): + guard let request = request(for: id, method: "loadingFinished", isolation: isolation) else { + return + } + request.finish(timestamp: timestamp, sourceMapURL: sourceMapURL, metrics: metrics) + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + case let .loadingFailed(id, errorText, canceled, timestamp): + guard let request = request(for: id, method: "loadingFailed", isolation: isolation) else { + return + } + request.fail(errorText: errorText, canceled: canceled, timestamp: timestamp) + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + case let .webSocket(event): + await apply(event, modelContext: modelContext, isolation: isolation) + case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): + await applyRequestServedFromMemoryCache( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext, + 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, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) 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, isolation: isolation) + inserted = true + } + if inserted { + await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + } else if topologyMayHaveChanged { + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + } + } + + private func applyRequestServedFromMemoryCache( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) 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, isolation: isolation) + request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) + await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + return + } + request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + } + + private func applyResponseReceived( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) 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, isolation: isolation) + inserted = true + } + request.applyResponse(response, resourceType: resourceType, timestamp: timestamp) + if inserted { + await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + } else { + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + } + } + + private func apply( + _ event: Network.WebSocketEvent, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + switch event { + case let .created(id, url): + await applyWebSocketCreated( + id: id, + url: url, + modelContext: modelContext, + isolation: isolation + ) + case let .handshakeRequest(id, payload, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketWillSendHandshakeRequest", + isolation: isolation + ) else { + return + } + networkRequest.applyWebSocketHandshakeRequest(payload, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case let .handshakeResponse(id, response, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketHandshakeResponseReceived", + isolation: isolation + ) else { + return + } + networkRequest.applyWebSocketHandshakeResponse(response, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case let .frameSent(id, frame, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameSent", + isolation: isolation + ) else { + return + } + networkRequest.appendWebSocketFrame(frame, direction: .sent, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case let .frameReceived(id, frame, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameReceived", + isolation: isolation + ) else { + return + } + networkRequest.appendWebSocketFrame(frame, direction: .received, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case let .error(id, message, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameError", + isolation: isolation + ) else { + return + } + networkRequest.appendWebSocketError(message, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case let .closed(id, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketClosed", + isolation: isolation + ) else { + return + } + networkRequest.closeWebSocket(timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + case .other: + break + } + } + + private func applyWebSocketCreated( + id proxyID: Network.Request.ID, + url: String, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) 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, isolation: isolation) + inserted = true + } + request.applyWebSocketCreated(url: url) + if inserted { + await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + } else { + await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + } + } + + private func request( + for proxyID: Network.Request.ID, + method: String, + isolation: isolated (any Actor) + ) -> NetworkRequest? { + _ = isolation + 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 removeAllRequests(isolation: isolated (any Actor)) { + _ = isolation + requestsByID = [:] + orderedRequestIDs = [] + orderIndicesByID = [:] + // The index is an actor. Mark it stale synchronously and replace it on + // the next async mutation instead of spawning unowned cleanup work. + queryIndexNeedsRebuild = true + collectionState.replaceCount(0) + resetFetchedResults(isolation: isolation) + } + + private func currentRequests(isolation: isolated (any Actor)) -> [NetworkRequest] { + _ = isolation +#if DEBUG + performanceCounters.fullModelProjectionCount += orderedRequestIDs.count +#endif + return orderedRequestIDs.compactMap { requestsByID[$0] } + } + + private func appendRequestID( + _ id: NetworkRequest.ID, + isolation: isolated (any Actor) + ) { + _ = isolation + orderIndicesByID[id] = orderedRequestIDs.count + orderedRequestIDs.append(id) + } + + private func currentRecordInputs( + isolation: isolated (any Actor) + ) -> [NetworkRequestRecordInput] { + _ = isolation +#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, + isolation: isolated (any Actor) + ) -> NetworkRequestRecordInput { + _ = isolation +#if DEBUG + performanceCounters.incrementalRecordProjectionCount += 1 +#endif + let orderIndex = orderIndicesByID[request.id] ?? orderedRequestIDs.count + return NetworkRequestRecordInput(request: request, orderIndex: orderIndex) + } + + private func isCurrent( + _ request: NetworkRequest, + isolation: isolated (any Actor) + ) -> Bool { + _ = isolation + return requestsByID[request.id] === request + } + + private func nextQueryIndexSequence(isolation: isolated (any Actor)) -> UInt64 { + _ = isolation + // 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 func syncQueryIndexIfNeeded(isolation: isolated (any Actor)) async { + guard queryIndexNeedsRebuild else { + return + } + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence(isolation: isolation) + let inputs = currentRecordInputs(isolation: isolation) + await queryIndex.replace(with: inputs, sequence: sequence) + } + + private func notifyRequestInserted( + _ request: NetworkRequest, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + collectionState.didInsertRequest() + await syncQueryIndexIfNeeded(isolation: isolation) + guard isCurrent(request, isolation: isolation) else { + return + } + let sequence = nextQueryIndexSequence(isolation: isolation) + let input = recordInput(for: request, isolation: isolation) + await queryIndex.upsert(input, sequence: sequence) + guard isCurrent(request, isolation: isolation) else { + return + } + await applyResultDeltas( + for: request, + inserted: true, + modelContext: modelContext, + isolation: isolation + ) + } + + private func notifyRequestMutated( + _ request: NetworkRequest, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + await syncQueryIndexIfNeeded(isolation: isolation) + guard isCurrent(request, isolation: isolation) else { + return + } + let sequence = nextQueryIndexSequence(isolation: isolation) + let input = recordInput(for: request, isolation: isolation) + await queryIndex.upsert(input, sequence: sequence) + guard isCurrent(request, isolation: isolation) else { + return + } + await applyResultDeltas( + for: request, + inserted: false, + modelContext: modelContext, + isolation: isolation + ) + } + + private func applyResultDeltas( + for request: NetworkRequest, + inserted: Bool, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + pruneFetchedResults(isolation: isolation) + for registration in fetchedResults { + guard let results = registration.value else { + continue + } + let plan = results.currentNetworkQueryPlan(context: modelContext) + 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 resultIndexSequence = results.networkIndexSequenceForDelta + let indexSequence = queryIndexSequence + let delta = await queryIndex.delta( + plan: plan, + sectionBy: results.sectionBy, + oldSnapshot: oldSnapshot, + changedSince: resultIndexSequence + ) + guard queryIndexSequence == indexSequence, + results.topologyRevision == resultTopologyRevision, + results.networkSnapshotForDelta == oldSnapshot else { + continue + } + results.applyNetworkDelta( + delta, + lookup: { id in self.requestForResult(id, isolation: isolation) } + ) + } + } + + private func requestForResult( + _ id: NetworkRequest.ID, + isolation: isolated (any Actor) + ) -> NetworkRequest? { + _ = isolation +#if DEBUG + performanceCounters.resultIdentityLookupCount += 1 +#endif + return requestsByID[id] + } + + private func pruneFetchedResults(isolation: isolated (any Actor)) { + _ = isolation + fetchedResults.removeAll { $0.value == nil } + } + + private func resetFetchedResults(isolation: isolated (any Actor)) { + pruneFetchedResults(isolation: isolation) + for registration in fetchedResults { + registration.value?.resetNetworkItems(indexSequence: queryIndexSequence) + } + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } +} diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 2f1cb159..3d491177 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -153,15 +153,7 @@ public final class WebInspectorContext { private var runtimeTrackingTarget: WebInspectorTarget? private var consoleTrackingTarget: WebInspectorTarget? 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 let networkRequests: NetworkRequestStore private var consoleMessagesByID: [ConsoleMessage.ID: ConsoleMessage] private var orderedConsoleMessageIDs: [ConsoleMessage.ID] private var lastConsoleMessageID: ConsoleMessage.ID? @@ -210,15 +202,7 @@ public final class WebInspectorContext { runtimeTrackingTarget = nil consoleTrackingTarget = nil statusRelay = WebInspectorAsyncStreamRelay() - requestsByID = [:] - orderedRequestIDs = [] - networkRequestOrderIndicesByID = [:] - clearedNetworkRequestIDs = [] - networkRequestIndex = NetworkRequestIndex() - networkRequestIndexSequence = 0 - networkRequestIndexNeedsRebuild = false - networkCollectionState = NetworkRequestCollectionState() - networkFetchedResults = [] + networkRequests = NetworkRequestStore() consoleMessagesByID = [:] orderedConsoleMessageIDs = [] lastConsoleMessageID = nil @@ -278,7 +262,7 @@ public final class WebInspectorContext { state = .attaching notifyStatusChanged() teardownError = nil - resetNetworkModelsForNewAttachment() + resetNetworkModelsForNewAttachment(isolation: isolation) startupTask = Task { [weak self, previousStartupTask, previousCurrentPageCleanupTask] in _ = isolation await previousStartupTask?.value @@ -322,11 +306,11 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) -> NetworkRequest? { requireOwner(isolation) - return requestsByID[id] + return networkRequests.request(for: id, isolation: isolation) } package var networkRequestsCollectionState: NetworkRequestCollectionState { - networkCollectionState + networkRequests.collectionState } package func registeredRequest( @@ -334,13 +318,13 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) -> NetworkRequest? { requireOwner(isolation) - return requestsByID[NetworkRequest.ID(id)] + return networkRequests.request(forProxyID: id, isolation: isolation) } /// Clears retained Network requests and emits reset transactions. public func clearNetworkRequests(isolation: isolated (any Actor) = #isolation) { requireOwner(isolation) - clearNetworkRequests() + networkRequests.clear(isolation: isolation) } /// Returns the registered Console message for an identifier. @@ -780,14 +764,7 @@ public final class WebInspectorContext { 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, - indexSequence: networkRequestIndexSequence, - lookup: { id in self.requestsByID[id] } - ) - networkFetchedResults.append(WeakWebInspectorFetchedResults(networkResults)) + networkRequests.register(networkResults, modelContext: self, isolation: isolation) case .consoleMessages: guard let consoleResults = results as? WebInspectorFetchedResults else { preconditionFailure("ConsoleMessage descriptors can only fetch ConsoleMessage models.") @@ -881,13 +858,11 @@ public final class WebInspectorContext { let networkResults = results as? WebInspectorFetchedResults else { preconditionFailure("NetworkRequest descriptors can only update NetworkRequest fetched results.") } - let plan = NetworkRequestQueryPlan(descriptor: networkDescriptor, context: self) - networkResults.applyNetworkFetchDescriptor( + networkRequests.updateFetchDescriptor( networkDescriptor, - plan: plan, - requests: currentNetworkRequests(), - indexSequence: networkRequestIndexSequence, - lookup: { id in self.requestsByID[id] } + for: networkResults, + modelContext: self, + isolation: isolation ) case .consoleMessages: guard let consoleDescriptor = descriptor as? WebInspectorFetchDescriptor, @@ -921,7 +896,8 @@ public final class WebInspectorContext { finishResponseBodyFetch( .failure(.disconnected("WebInspectorDataKit has no current page target.")), for: request, - expectedBody: expectedBody + expectedBody: expectedBody, + isolation: isolation ) return } @@ -931,9 +907,19 @@ public final class WebInspectorContext { for: request.proxyID, backendResourceIdentifier: request.backendResourceIdentifier ) - finishResponseBodyFetch(.success(body), for: request, expectedBody: expectedBody) + finishResponseBodyFetch( + .success(body), + for: request, + expectedBody: expectedBody, + isolation: isolation + ) } catch let error as WebInspectorProxyError { - finishResponseBodyFetch(.failure(error), for: request, expectedBody: expectedBody) + finishResponseBodyFetch( + .failure(error), + for: request, + expectedBody: expectedBody, + isolation: isolation + ) } catch { finishResponseBodyFetch( .failure(.commandFailed( @@ -942,7 +928,8 @@ public final class WebInspectorContext { message: String(describing: error) )), for: request, - expectedBody: expectedBody + expectedBody: expectedBody, + isolation: isolation ) } } @@ -950,12 +937,15 @@ public final class WebInspectorContext { private func finishResponseBodyFetch( _ result: Result, for request: NetworkRequest, - expectedBody: NetworkBody + expectedBody: NetworkBody, + isolation: isolated (any Actor) ) { - guard requestsByID[request.id] === request else { - return - } - request.finishResponseBodyFetch(result: result, expectedBody: expectedBody) + networkRequests.finishResponseBodyFetch( + result, + for: request, + expectedBody: expectedBody, + isolation: isolation + ) } func requestChildren( @@ -1429,15 +1419,10 @@ public final class WebInspectorContext { refreshAllConsoleMessages() } - private func resetNetworkModelsForNewAttachment() { - clearedNetworkRequestIDs = [] - requestsByID = [:] - orderedRequestIDs = [] - networkRequestOrderIndicesByID = [:] - networkRequestIndexNeedsRebuild = true - clearNetworkRequestIndex() - networkCollectionState.replaceCount(0) - resetNetworkFetchedResults() + private func resetNetworkModelsForNewAttachment( + isolation: isolated (any Actor) + ) { + networkRequests.resetForNewAttachment(isolation: isolation) } private func resetCurrentPageLifecycleModels(isolation: isolated (any Actor)) { @@ -1448,7 +1433,7 @@ public final class WebInspectorContext { private func resetAttachmentBackedModels(isolation: isolated (any Actor)) { resetCurrentPageLifecycleModels(isolation: isolation) - resetNetworkModelsForNewAttachment() + resetNetworkModelsForNewAttachment(isolation: isolation) } private func disableEnabledDomains( @@ -2680,85 +2665,23 @@ extension WebInspectorContext { 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, + return networkRequests.seedRequest( + requestID: rawRequestID, 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 - ) + resourceTypeRawValue: resourceTypeRawValue, + requestHeaders: requestHeaders, + postData: postData, + responseMIMEType: responseMIMEType, + responseStatus: responseStatus, + responseStatusText: responseStatusText, + responseHeaders: responseHeaders, + responseBody: responseBody, + timestamp: timestamp, + encodedBodyLength: encodedBodyLength, + modelContext: self, + isolation: isolation ) - 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( @@ -2770,435 +2693,22 @@ extension WebInspectorContext { 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( + networkRequests.seedResponseBody( + for: requestID, 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 { - // The index drains a contiguous operation log, so every allocated - // sequence must be submitted exactly once to replace or upsert. - precondition( - networkRequestIndexSequence < UInt64.max, - "NetworkRequestIndex mutation sequence overflowed." + isTruncated: isTruncated, + isolation: isolation ) - 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) + package func apply( + _ event: Network.Event, + isolation: isolated (any Actor) = #isolation ) 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 resultIndexSequence = results.networkIndexSequenceForDelta - let indexSequence = networkRequestIndexSequence - let delta = await networkRequestIndex.delta( - plan: plan, - sectionBy: results.sectionBy, - oldSnapshot: oldSnapshot, - changedSince: resultIndexSequence - ) - 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(indexSequence: networkRequestIndexSequence) - } + requireOwner(isolation) + await networkRequests.apply(event, modelContext: self, isolation: isolation) } } diff --git a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift new file mode 100644 index 00000000..75208c9e --- /dev/null +++ b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func networkRequestStoreOwnsIdentityOrderAndClearEpoch() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + let registeredFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) + let first = try #require(registeredFirst) + + await store.apply( + .responseReceived( + id: firstProxyID, + response: Network.Response(url: "https://example.com/first", status: 200), + resourceType: .fetch, + timestamp: 2 + ), + modelContext: context, + isolation: MainActor.shared + ) + 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, + isolation: MainActor.shared + ) + + let updatedFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) + #expect(updatedFirst === first) + #expect(first.statusCode == 200) + #expect(store.collectionState.requestCount == 2) + + store.clear(isolation: MainActor.shared) + + let clearedFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) + let clearedSecond = store.request(forProxyID: secondProxyID, isolation: MainActor.shared) + #expect(clearedFirst == nil) + #expect(clearedSecond == nil) + #expect(store.collectionState.requestCount == 0) + #expect(store.collectionState.topologyRevision == 3) +} + +@MainActor +@Test +func networkRequestStorePropertyMutationAtTenThousandRecordsUsesOneCompactProjection() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = NetworkRequestStore() + let recordCount = 10_000 + + for ordinal in 0..( + fetchDescriptor: WebInspectorFetchDescriptor(), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + #expect(results.items.count == recordCount) + store.resetPerformanceCountersForTesting(isolation: MainActor.shared) + + let lastID = Network.Request.ID("request-\(recordCount - 1)") + await store.apply( + .dataReceived( + id: lastID, + dataLength: 1, + encodedDataLength: 1, + timestamp: Double(recordCount) + ), + modelContext: context, + isolation: MainActor.shared + ) + + let counters = store.performanceCountersForTesting + #expect(counters.incrementalRecordProjectionCount == 1) + #expect(counters.fullRecordProjectionCount == 0) + #expect(counters.fullModelProjectionCount == 0) + #expect(counters.resultIdentityLookupCount == 0) + #expect(results.items.count == recordCount) + #expect(results.items.last?.encodedDataLength == 1) +} From c94e5173478f64b21a576fbae35c22f66b1babe8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:55:51 +0900 Subject: [PATCH 22/81] refactor(data): move Console state to its owner --- .../ConsoleMessageIndex.swift | 222 +++++++ .../ConsoleMessageQuery.swift | 583 ++++++++++++++++++ .../ConsoleMessageStore.swift | 494 +++++++++++++++ Sources/WebInspectorDataKit/Fetching.swift | 191 +++++- .../WebInspectorContext.swift | 195 ++---- .../ConsoleMessageStoreTests.swift | 243 ++++++++ .../NetworkRequestStoreTests.swift | 6 + .../WebInspectorDataKitTests.swift | 8 +- 8 files changed, 1758 insertions(+), 184 deletions(-) create mode 100644 Sources/WebInspectorDataKit/ConsoleMessageIndex.swift create mode 100644 Sources/WebInspectorDataKit/ConsoleMessageQuery.swift create mode 100644 Sources/WebInspectorDataKit/ConsoleMessageStore.swift create mode 100644 Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift diff --git a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift new file mode 100644 index 00000000..efaf994f --- /dev/null +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -0,0 +1,222 @@ +import Foundation + +package struct ConsoleResultSetDelta: Sendable { + package var sequence: UInt64 + package var snapshot: WebInspectorFetchedResultsSnapshot + package var transaction: WebInspectorFetchedResultsTransaction + package var reconfigureItemIDs: Set +} + +/// Owns compact Console query records away from the model-context actor. +package actor ConsoleMessageIndex { + private enum Mutation { + case replace([ConsoleMessageRecordInput]) + case upsert(ConsoleMessageRecordInput) + } + + private struct PendingMutation { + var mutation: Mutation + var continuation: CheckedContinuation + } + + private var recordsByID: [ConsoleMessage.ID: ConsoleMessageRecord] = [:] + private var orderedIDs: [ConsoleMessage.ID] = [] + private var lastUpdatedSequenceByID: [ConsoleMessage.ID: UInt64] = [:] + private var lastAppliedSequence: UInt64 = 0 + private var pendingMutationsBySequence: [UInt64: PendingMutation] = [:] + + package init() {} + + package func replace(with inputs: [ConsoleMessageRecordInput], sequence: UInt64) async { + await enqueue(.replace(inputs), sequence: sequence) + } + + package func upsert(_ input: ConsoleMessageRecordInput, sequence: UInt64) async { + await enqueue(.upsert(input), sequence: sequence) + } + + private func enqueue(_ mutation: Mutation, sequence: UInt64) async { + precondition( + sequence > lastAppliedSequence, + "ConsoleMessageIndex received an already-applied mutation sequence." + ) + precondition( + pendingMutationsBySequence[sequence] == nil, + "ConsoleMessageIndex received a duplicate mutation sequence." + ) + 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 { + return + } + apply(pending.mutation, sequence: sequence) + lastAppliedSequence = sequence + pending.continuation.resume() + } + precondition( + pendingMutationsBySequence.isEmpty, + "ConsoleMessageIndex mutation sequence overflowed." + ) + } + + private func apply(_ mutation: Mutation, sequence: UInt64) { + switch mutation { + case let .replace(inputs): + recordsByID = [:] + recordsByID.reserveCapacity(inputs.count) + orderedIDs = [] + orderedIDs.reserveCapacity(inputs.count) + lastUpdatedSequenceByID = [:] + lastUpdatedSequenceByID.reserveCapacity(inputs.count) + for input in inputs { + upsertRecord(input, sequence: sequence) + } + case let .upsert(input): + upsertRecord(input, sequence: sequence) + } + } + + 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 delta( + plan: ConsoleMessageQueryPlan, + sectionBy: WebInspectorSectionDescriptor?, + oldSnapshot: WebInspectorFetchedResultsSnapshot, + changedSince sequence: UInt64 + ) -> ConsoleResultSetDelta { + precondition(plan.requiresModelPredicate == false) + let indexSequence = lastAppliedSequence + let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) + let oldItemIDs = Set(oldSnapshot.itemIDs) + let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in + oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence + }) + let transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot, + updatedItemIDs: updatedItemIDs + ) + return ConsoleResultSetDelta( + sequence: indexSequence, + snapshot: newSnapshot, + transaction: transaction, + reconfigureItemIDs: updatedItemIDs + ) + } + +#if DEBUG + package func isMutationPendingForTesting(sequence: UInt64) -> Bool { + pendingMutationsBySequence[sequence] != nil + } +#endif + + private func snapshot( + plan: ConsoleMessageQueryPlan, + sectionBy: WebInspectorSectionDescriptor? + ) -> WebInspectorFetchedResultsSnapshot { + let matchingRecords = visibleRecords(plan: plan) + guard matchingRecords.isEmpty == false else { + return WebInspectorFetchedResultsSnapshot() + } + guard let sectionBy else { + return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) + } + + var sections: [( + id: WebInspectorFetchSectionID, + title: String?, + itemIDs: [ConsoleMessage.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] + )) + } + } + return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in + WebInspectorFetchedResultsSnapshot.Section( + id: section.id, + title: section.title, + itemIDs: section.itemIDs + ) + }) + } + + private func visibleRecords(plan: ConsoleMessageQueryPlan) -> [ConsoleMessageRecord] { + var records: [ConsoleMessageRecord] = [] + 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) + } + + if plan.sortComparators.isEmpty == false { + records.sort { lhs, rhs in + plan.ordersBefore(lhs, rhs) + } + } + + 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 .consoleSource: + value = record.sourceRawValue + case .consoleLevel: + value = record.levelRawValue + case .consoleKind: + value = record.kindRawValue + case .consoleURL: + value = record.url + case .networkMethod, + .networkResourceType, + .networkResourceCategory, + .networkMIMEType: + preconditionFailure("Network section descriptors cannot be applied to ConsoleMessage results.") + } + + let title = value ?? "" + return (WebInspectorFetchSectionID(rawValue: title), title) + } +} diff --git a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift new file mode 100644 index 00000000..23e11c26 --- /dev/null +++ b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift @@ -0,0 +1,583 @@ +import Foundation +import WebInspectorProxyKit + +package struct ConsoleMessageRecordInput: Hashable, Sendable { + package var id: ConsoleMessage.ID + package var orderIndex: Int + package var sourceRawValue: String + package var levelRawValue: String + package var kindRawValue: String? + package var text: String + package var url: String? + package var line: Int? + package var column: Int? + package var repeatCount: Int + package var timestamp: Double? + + package init(message: ConsoleMessage, orderIndex: Int) { + id = message.id + self.orderIndex = orderIndex + sourceRawValue = message.source.rawValue + levelRawValue = message.level.rawValue + kindRawValue = message.kind?.rawValue + text = message.text + url = message.url + line = message.line + column = message.column + repeatCount = message.repeatCount + timestamp = message.timestamp + } +} + +package struct ConsoleMessageRecord: Hashable, Sendable { + package var id: ConsoleMessage.ID + package var orderIndex: Int + package var sourceRawValue: String + package var levelRawValue: String + package var kindRawValue: String? + package var text: String + package var url: String? + package var line: Int? + package var column: Int? + package var repeatCount: Int + package var timestamp: Double? + + package init(input: ConsoleMessageRecordInput) { + id = input.id + orderIndex = input.orderIndex + sourceRawValue = input.sourceRawValue + levelRawValue = input.levelRawValue + kindRawValue = input.kindRawValue + text = input.text + url = input.url + line = input.line + column = input.column + repeatCount = input.repeatCount + timestamp = input.timestamp + } + + package init(message: ConsoleMessage, orderIndex: Int) { + self.init(input: ConsoleMessageRecordInput(message: message, orderIndex: orderIndex)) + } +} + +package struct ConsoleMessageQueryPlan: Sendable { + package typealias RecordPredicate = @Sendable (ConsoleMessageRecord) -> Bool + + package enum Filter: Sendable { + case record(RecordPredicate) + case model(Predicate) + + package func matches(record: ConsoleMessageRecord) -> Bool? { + switch self { + case let .record(predicate): + return predicate(record) + case .model: + return nil + } + } + + package func matches(record: ConsoleMessageRecord, message: ConsoleMessage) -> Bool { + switch self { + case let .record(predicate): + return predicate(record) + case let .model(predicate): + do { + return try predicate.evaluate(message) + } catch { + preconditionFailure("ConsoleMessage predicate evaluation failed: \(error)") + } + } + } + } + + package var filter: Filter? + package var sortComparators: [ConsoleMessageRecordSortComparator] + package var fetchLimit: Int? + package var fetchOffset: Int + + package init( + descriptor: WebInspectorFetchDescriptor, + context: WebInspectorContext + ) { + filter = descriptor.predicate.map(makeConsoleMessageFilter) + sortComparators = descriptor.sortBy.map { + ConsoleMessageRecordSortComparator(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: ConsoleMessageRecord) -> Bool? { + filter?.matches(record: record) ?? true + } + + package func matches(record: ConsoleMessageRecord, message: ConsoleMessage) -> Bool { + filter?.matches(record: record, message: message) ?? true + } + + package func visibleIDs(from matchingIDs: [ConsoleMessage.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 ConsoleMessageQueryState { + package var plan: ConsoleMessageQueryPlan + private var recordsByID: [ConsoleMessage.ID: ConsoleMessageRecord] + private var matchingIDs: [ConsoleMessage.ID] + + package init(plan: ConsoleMessageQueryPlan, messages: [ConsoleMessage]) { + self.plan = plan + recordsByID = [:] + recordsByID.reserveCapacity(messages.count) + matchingIDs = [] + matchingIDs.reserveCapacity(messages.count) + for (index, message) in messages.enumerated() { + upsert(message: message, orderIndex: index) + } + } + + private mutating func upsert(message: ConsoleMessage, orderIndex: Int) { + let record = ConsoleMessageRecord(message: message, orderIndex: orderIndex) + recordsByID[record.id] = record + matchingIDs.removeAll { $0 == record.id } + guard plan.matches(record: record, message: message) else { + return + } + insertMatchingID(record.id) + } + + package mutating func upsert(message: ConsoleMessage) { + let orderIndex = recordsByID[message.id]?.orderIndex ?? recordsByID.count + upsert(message: message, orderIndex: orderIndex) + } + + package func visibleMessages( + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) -> [ConsoleMessage] { + plan.visibleIDs(from: matchingIDs).compactMap(lookup) + } + + private mutating func insertMatchingID(_ id: ConsoleMessage.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 ConsoleMessageRecordSortComparator: Sendable { + private enum Key: Sendable { + case source + case level + case kind + case text + case url + case line + case column + case repeatCount + case timestamp + case id + } + + 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 ConsoleMessage sort descriptor: \(descriptor)") + } + self.key = key + order = descriptor.order + } + + fileprivate func compare( + _ lhs: ConsoleMessageRecord, + _ rhs: ConsoleMessageRecord + ) -> ComparisonResult { + let result: ComparisonResult + switch key { + case .source: + result = compareValues(lhs.sourceRawValue, rhs.sourceRawValue) + case .level: + result = compareValues(lhs.levelRawValue, rhs.levelRawValue) + case .kind: + result = compareOptional(lhs.kindRawValue, rhs.kindRawValue) + case .text: + result = compareValues(lhs.text, rhs.text) + case .url: + result = compareOptional(lhs.url, rhs.url) + case .line: + result = compareOptional(lhs.line, rhs.line) + case .column: + result = compareOptional(lhs.column, rhs.column) + case .repeatCount: + result = compareValues(lhs.repeatCount, rhs.repeatCount) + case .timestamp: + result = compareOptional(lhs.timestamp, rhs.timestamp) + case .id: + result = compareValues(lhs.id, rhs.id) + } + switch order { + case .forward: + return result + case .reverse: + return result.reversedForConsoleQuery + } + } + + fileprivate var usesReverseOrder: Bool { + order == .reverse + } + + private static func key( + for descriptor: SortDescriptor, + context: WebInspectorContext + ) -> Key? { + let candidates: [(Key, Console.Message, Console.Message, ConsoleMessage.ID, ConsoleMessage.ID)] = [ + (.source, payload(source: "a"), payload(source: "b"), .init(0), .init(0)), + (.level, payload(level: "a"), payload(level: "b"), .init(0), .init(0)), + (.kind, payload(kind: "a"), payload(kind: "b"), .init(0), .init(0)), + (.text, payload(text: "a"), payload(text: "b"), .init(0), .init(0)), + (.url, payload(url: "a"), payload(url: "b"), .init(0), .init(0)), + (.line, payload(line: 1), payload(line: 2), .init(0), .init(0)), + (.column, payload(column: 1), payload(column: 2), .init(0), .init(0)), + (.repeatCount, payload(repeatCount: 1), payload(repeatCount: 2), .init(0), .init(0)), + (.timestamp, payload(timestamp: 1), payload(timestamp: 2), .init(0), .init(0)), + (.id, payload(), payload(), .init(1), .init(2)), + ] + for (key, lhsPayload, rhsPayload, lhsID, rhsID) in candidates { + let lhs = ConsoleMessage( + id: lhsID, + message: lhsPayload, + parameters: [], + targetID: nil, + modelContext: context + ) + let rhs = ConsoleMessage( + id: rhsID, + message: rhsPayload, + parameters: [], + targetID: nil, + modelContext: context + ) + if descriptor.compare(lhs, rhs) != .orderedSame { + return key + } + } + return nil + } + + private static func payload( + source: String = "source", + level: String = "level", + kind: String? = nil, + text: String = "text", + url: String? = nil, + line: Int? = nil, + column: Int? = nil, + repeatCount: Int = 1, + timestamp: Double? = nil + ) -> Console.Message { + Console.Message( + source: Console.Source(rawValue: source), + level: Console.Level(rawValue: level), + type: kind.map(Console.Kind.init(rawValue:)), + text: text, + url: url, + line: line, + column: column, + repeatCount: repeatCount, + timestamp: timestamp + ) + } + + private func compareValues(_ lhs: Value, _ rhs: Value) -> ComparisonResult { + if lhs < rhs { + return .orderedAscending + } + if lhs > rhs { + return .orderedDescending + } + return .orderedSame + } + + 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?): + return compareValues(lhs, rhs) + } + } +} + +private extension ComparisonResult { + var reversedForConsoleQuery: ComparisonResult { + switch self { + case .orderedAscending: + return .orderedDescending + case .orderedDescending: + return .orderedAscending + case .orderedSame: + return .orderedSame + } + } +} + +private protocol ConsoleMessageRecordPredicateExpression { + func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate +} + +private enum ConsoleMessageRecordPredicateValue: Equatable, Sendable { + case string(String) + case optionalString(String?) + case integer(Int) + case optionalInteger(Int?) +} + +private protocol ConsoleMessageRecordEquatableExpression { + func consoleMessageEquatableExpression() throws + -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue +} + +private protocol ConsoleMessageRecordStringExpression { + func consoleMessageStringExpression() throws -> @Sendable (ConsoleMessageRecord) -> String +} + +private protocol ConsoleMessageRecordLevelExpression { + func consoleMessageLevelExpression() throws -> @Sendable (ConsoleMessageRecord) -> Console.Level +} + +private struct UnsupportedConsoleMessageRecordPredicate: Error {} + +private func makeConsoleMessageFilter( + _ predicate: Predicate +) -> ConsoleMessageQueryPlan.Filter { + guard let expression = predicate.expression as? any ConsoleMessageRecordPredicateExpression else { + return .model(predicate) + } + do { + return .record(try expression.consoleMessageRecordPredicate()) + } catch is UnsupportedConsoleMessageRecordPredicate { + return .model(predicate) + } catch { + preconditionFailure("ConsoleMessage predicate planning failed: \(error)") + } +} + +extension PredicateExpressions.Conjunction: ConsoleMessageRecordPredicateExpression + where LHS: ConsoleMessageRecordPredicateExpression, RHS: ConsoleMessageRecordPredicateExpression +{ + fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { + let lhsPredicate = try lhs.consoleMessageRecordPredicate() + let rhsPredicate = try rhs.consoleMessageRecordPredicate() + return { record in + lhsPredicate(record) && rhsPredicate(record) + } + } +} + +extension PredicateExpressions.Disjunction: ConsoleMessageRecordPredicateExpression + where LHS: ConsoleMessageRecordPredicateExpression, RHS: ConsoleMessageRecordPredicateExpression +{ + fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { + let lhsPredicate = try lhs.consoleMessageRecordPredicate() + let rhsPredicate = try rhs.consoleMessageRecordPredicate() + return { record in + lhsPredicate(record) || rhsPredicate(record) + } + } +} + +extension PredicateExpressions.Equal: ConsoleMessageRecordPredicateExpression + where LHS: ConsoleMessageRecordEquatableExpression, RHS: ConsoleMessageRecordEquatableExpression +{ + fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { + let lhsExpression = try lhs.consoleMessageEquatableExpression() + let rhsExpression = try rhs.consoleMessageEquatableExpression() + return { record in + consoleMessagePredicateValuesEqual(lhsExpression(record), rhsExpression(record)) + } + } +} + +private func consoleMessagePredicateValuesEqual( + _ lhs: ConsoleMessageRecordPredicateValue, + _ rhs: ConsoleMessageRecordPredicateValue +) -> 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 (.integer(lhs), .integer(rhs)): + return lhs == rhs + case let (.optionalInteger(lhs), .optionalInteger(rhs)): + return lhs == rhs + case let (.optionalInteger(lhs), .integer(rhs)): + return lhs == rhs + case let (.integer(lhs), .optionalInteger(rhs)): + return lhs == rhs + default: + return false + } +} + +extension PredicateExpressions.StringLocalizedStandardContains: ConsoleMessageRecordPredicateExpression + where Root: ConsoleMessageRecordStringExpression, Other: ConsoleMessageRecordStringExpression +{ + fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { + let rootExpression = try root.consoleMessageStringExpression() + let otherExpression = try other.consoleMessageStringExpression() + return { record in + rootExpression(record).localizedStandardContains(otherExpression(record)) + } + } +} + +extension PredicateExpressions.KeyPath: ConsoleMessageRecordStringExpression { + fileprivate func consoleMessageStringExpression() throws + -> @Sendable (ConsoleMessageRecord) -> String { + let keyPath = keyPath as AnyKeyPath + if let levelRoot = root as? any ConsoleMessageRecordLevelExpression, + keyPath == (\Console.Level.rawValue as AnyKeyPath) { + let levelExpression = try levelRoot.consoleMessageLevelExpression() + return { levelExpression($0).rawValue } + } + if keyPath == (\ConsoleMessage.text as AnyKeyPath) { + return { $0.text } + } + throw UnsupportedConsoleMessageRecordPredicate() + } +} + +extension PredicateExpressions.KeyPath: ConsoleMessageRecordLevelExpression + where Root == PredicateExpressions.Variable, Output == Console.Level +{ + fileprivate func consoleMessageLevelExpression() throws + -> @Sendable (ConsoleMessageRecord) -> Console.Level { + guard keyPath == \ConsoleMessage.level else { + throw UnsupportedConsoleMessageRecordPredicate() + } + return { Console.Level(rawValue: $0.levelRawValue) } + } +} + +extension PredicateExpressions.KeyPath: ConsoleMessageRecordEquatableExpression { + fileprivate func consoleMessageEquatableExpression() throws + -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue { + if let stringExpression = try? consoleMessageStringExpression() { + return { .string(stringExpression($0)) } + } + let keyPath = keyPath as AnyKeyPath + if keyPath == (\ConsoleMessage.kind?.rawValue as AnyKeyPath) { + return { .optionalString($0.kindRawValue) } + } + if keyPath == (\ConsoleMessage.url as AnyKeyPath) { + return { .optionalString($0.url) } + } + if keyPath == (\ConsoleMessage.repeatCount as AnyKeyPath) { + return { .integer($0.repeatCount) } + } + if keyPath == (\ConsoleMessage.line as AnyKeyPath) { + return { .optionalInteger($0.line) } + } + if keyPath == (\ConsoleMessage.column as AnyKeyPath) { + return { .optionalInteger($0.column) } + } + throw UnsupportedConsoleMessageRecordPredicate() + } +} + +extension PredicateExpressions.Value: ConsoleMessageRecordStringExpression where Output == String { + fileprivate func consoleMessageStringExpression() throws + -> @Sendable (ConsoleMessageRecord) -> String { + let value = value + return { _ in value } + } +} + +extension PredicateExpressions.Value: ConsoleMessageRecordEquatableExpression { + fileprivate func consoleMessageEquatableExpression() throws + -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue { + 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? Int { + return { _ in .integer(value) } + } + if Output.self == Optional.self { + let value = value as! Int? + return { _ in .optionalInteger(value) } + } + throw UnsupportedConsoleMessageRecordPredicate() + } +} diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift new file mode 100644 index 00000000..066bf575 --- /dev/null +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -0,0 +1,494 @@ +import WebInspectorProxyKit + +/// Owns Console message identity, order, query projection, and publication. +/// +/// Runtime-object membership and protocol commands remain in +/// `WebInspectorContext`; clear operations return explicit effects for that +/// owner to apply. The store never retains an actor token or starts a task. +package final class ConsoleMessageStore { + package enum RuntimeObjectGroupRelease: Equatable { + case currentPage + case target(WebInspectorTarget.ID) + } + + package struct Effects { + package var removedMessages: [ConsoleMessage] = [] + package var runtimeObjectsToUnregister: [RuntimeObject] = [] + package var clearedAllMessages = false + package var runtimeObjectGroupRelease: RuntimeObjectGroupRelease? + } + +#if DEBUG + package struct PerformanceCounters: Equatable { + package var fullModelProjectionCount = 0 + package var fullRecordProjectionCount = 0 + package var incrementalRecordProjectionCount = 0 + package var resultIdentityLookupCount = 0 + } +#endif + + package private(set) var collectionEpoch: UInt64 + + 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 fetchedResults: [WeakWebInspectorFetchedResults] +#if DEBUG + private var performanceCounters: PerformanceCounters +#endif + + package init() { + collectionEpoch = 0 + messagesByID = [:] + orderedMessageIDs = [] + orderIndicesByID = [:] + lastMessageID = nil + lastMessageIDByTargetID = [:] + nextMessageOrdinal = 0 + queryIndex = ConsoleMessageIndex() + queryIndexSequence = 0 + queryIndexNeedsRebuild = false + fetchedResults = [] +#if DEBUG + performanceCounters = PerformanceCounters() +#endif + } + +#if DEBUG + package var performanceCountersForTesting: PerformanceCounters { + performanceCounters + } + + package func resetPerformanceCountersForTesting( + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + performanceCounters = PerformanceCounters() + } +#endif + + package func message( + for id: ConsoleMessage.ID, + isolation: isolated (any Actor) = #isolation + ) -> ConsoleMessage? { + _ = isolation + return messagesByID[id] + } + + package func register( + _ results: WebInspectorFetchedResults, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) { + let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) + results.setConsoleItems( + currentMessages(isolation: isolation), + plan: plan, + indexSequence: queryIndexSequence, + lookup: { id in self.messagesByID[id] } + ) + fetchedResults.append(WeakWebInspectorFetchedResults(results)) + } + + package func updateFetchDescriptor( + _ descriptor: WebInspectorFetchDescriptor, + for results: WebInspectorFetchedResults, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) { + let plan = ConsoleMessageQueryPlan(descriptor: descriptor, context: modelContext) + results.applyConsoleFetchDescriptor( + descriptor, + plan: plan, + messages: currentMessages(isolation: isolation), + indexSequence: queryIndexSequence, + lookup: { id in self.messagesByID[id] } + ) + } + + package func apply( + _ event: Console.Event, + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorContext, + registerRuntimeObject: (Runtime.RemoteObject) -> RuntimeObject, + isolation: isolated (any Actor) = #isolation + ) async -> Effects { + switch event { + case let .messageAdded(payload): + let parameters = payload.parameters.map(registerRuntimeObject) + await insertMessage( + payload, + parameters: parameters, + targetID: targetID, + modelContext: modelContext, + isolation: isolation + ) + return Effects() + case let .messageRepeatCountUpdated(count, timestamp): + await updateRepeatCount( + count, + timestamp: timestamp, + targetID: targetID, + modelContext: modelContext, + isolation: isolation + ) + return Effects() + case .messagesCleared: + return clear(targetID: targetID, modelContext: modelContext, isolation: isolation) + case .unknown: + return Effects() + } + } + + package func resetForReplay( + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) { + _ = removeMessages(targetID: nil, isolation: isolation) + refreshAllResults(modelContext: modelContext, isolation: isolation) + } + + package func clearForLifecycle( + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> Effects { + let removedMessages = removeMessages(targetID: nil, isolation: isolation) + refreshAllResults(modelContext: modelContext, isolation: isolation) + return Effects( + removedMessages: removedMessages, + clearedAllMessages: true + ) + } + + private func insertMessage( + _ payload: Console.Message, + parameters: [RuntimeObject], + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) 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 + } + collectionEpoch &+= 1 + await notifyMessageInserted(message, modelContext: modelContext, isolation: isolation) + } + + private func updateRepeatCount( + _ count: Int, + timestamp: Double?, + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + let candidateID = targetID.flatMap { lastMessageIDByTargetID[$0] } ?? 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, isolation: isolation) + } + + private func clear( + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) -> Effects { + let removedMessages = removeMessages(targetID: targetID, isolation: isolation) + let runtimeObjectsToUnregister = targetID == nil + ? [] + : unreferencedRuntimeObjects( + from: removedMessages, + isolation: isolation + ) + refreshAllResults(modelContext: modelContext, isolation: isolation) + return Effects( + removedMessages: removedMessages, + runtimeObjectsToUnregister: runtimeObjectsToUnregister, + clearedAllMessages: targetID == nil, + runtimeObjectGroupRelease: targetID.map(RuntimeObjectGroupRelease.target) ?? .currentPage + ) + } + + @discardableResult + private func removeMessages( + targetID: WebInspectorTarget.ID?, + isolation: isolated (any Actor) + ) -> [ConsoleMessage] { + _ = isolation + collectionEpoch &+= 1 + queryIndexNeedsRebuild = true + 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(isolation: isolation) + 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], + isolation: isolated (any Actor) + ) -> [RuntimeObject] { + _ = isolation + 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(isolation: isolated (any Actor)) { + _ = isolation + orderIndicesByID = Dictionary( + uniqueKeysWithValues: orderedMessageIDs.enumerated().map { index, id in + (id, index) + } + ) + } + + private func currentMessages(isolation: isolated (any Actor)) -> [ConsoleMessage] { + _ = isolation +#if DEBUG + performanceCounters.fullModelProjectionCount += orderedMessageIDs.count +#endif + return orderedMessageIDs.compactMap { messagesByID[$0] } + } + + private func currentRecordInputs( + isolation: isolated (any Actor) + ) -> [ConsoleMessageRecordInput] { + _ = isolation +#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, + isolation: isolated (any Actor) + ) -> ConsoleMessageRecordInput { + _ = isolation +#if DEBUG + performanceCounters.incrementalRecordProjectionCount += 1 +#endif + let orderIndex = orderIndicesByID[message.id] ?? orderedMessageIDs.count + return ConsoleMessageRecordInput(message: message, orderIndex: orderIndex) + } + + private func isCurrent( + _ message: ConsoleMessage, + isolation: isolated (any Actor) + ) -> Bool { + _ = isolation + return messagesByID[message.id] === message + } + + private func nextQueryIndexSequence(isolation: isolated (any Actor)) -> UInt64 { + _ = isolation + precondition( + queryIndexSequence < UInt64.max, + "ConsoleMessageIndex mutation sequence overflowed." + ) + queryIndexSequence += 1 + return queryIndexSequence + } + + private func syncQueryIndexIfNeeded(isolation: isolated (any Actor)) async { + guard queryIndexNeedsRebuild else { + return + } + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence(isolation: isolation) + let inputs = currentRecordInputs(isolation: isolation) + await queryIndex.replace(with: inputs, sequence: sequence) + } + + private func notifyMessageInserted( + _ message: ConsoleMessage, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + await syncQueryIndexIfNeeded(isolation: isolation) + guard isCurrent(message, isolation: isolation) else { + return + } + let sequence = nextQueryIndexSequence(isolation: isolation) + let input = recordInput(for: message, isolation: isolation) + await queryIndex.upsert(input, sequence: sequence) + guard isCurrent(message, isolation: isolation) else { + return + } + await applyResultDeltas( + for: message, + inserted: true, + modelContext: modelContext, + isolation: isolation + ) + } + + private func notifyMessageMutated( + _ message: ConsoleMessage, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + await syncQueryIndexIfNeeded(isolation: isolation) + guard isCurrent(message, isolation: isolation) else { + return + } + let sequence = nextQueryIndexSequence(isolation: isolation) + let input = recordInput(for: message, isolation: isolation) + await queryIndex.upsert(input, sequence: sequence) + guard isCurrent(message, isolation: isolation) else { + return + } + await applyResultDeltas( + for: message, + inserted: false, + modelContext: modelContext, + isolation: isolation + ) + } + + private func applyResultDeltas( + for message: ConsoleMessage, + inserted: Bool, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) async { + pruneFetchedResults(isolation: isolation) + for registration in fetchedResults { + guard let results = registration.value else { + continue + } + let plan = results.currentConsoleQueryPlan(context: modelContext) + if plan.requiresModelPredicate { + if inserted { + results.insertConsoleMessage( + message, + lookup: { id in self.messagesByID[id] } + ) + } else { + results.refreshConsoleMessageAfterMutation( + message, + lookup: { id in self.messagesByID[id] } + ) + } + continue + } + let oldSnapshot = results.consoleSnapshotForDelta + let resultTopologyRevision = results.topologyRevision + let resultIndexSequence = results.consoleIndexSequenceForDelta + let indexSequence = queryIndexSequence + let delta = await queryIndex.delta( + plan: plan, + sectionBy: results.sectionBy, + oldSnapshot: oldSnapshot, + changedSince: resultIndexSequence + ) + guard queryIndexSequence == indexSequence, + results.topologyRevision == resultTopologyRevision, + results.consoleSnapshotForDelta == oldSnapshot else { + continue + } + results.applyConsoleDelta( + delta, + lookup: { id in self.messageForResult(id, isolation: isolation) } + ) + } + } + + private func refreshAllResults( + modelContext: WebInspectorContext, + isolation: isolated (any Actor) + ) { + pruneFetchedResults(isolation: isolation) + let messages = currentMessages(isolation: isolation) + for registration in fetchedResults { + guard let results = registration.value else { + continue + } + let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) + results.setConsoleItems( + messages, + plan: plan, + indexSequence: queryIndexSequence, + lookup: { id in self.messagesByID[id] } + ) + } + } + + private func messageForResult( + _ id: ConsoleMessage.ID, + isolation: isolated (any Actor) + ) -> ConsoleMessage? { + _ = isolation +#if DEBUG + performanceCounters.resultIdentityLookupCount += 1 +#endif + return messagesByID[id] + } + + private func pruneFetchedResults(isolation: isolated (any Actor)) { + _ = isolation + fetchedResults.removeAll { $0.value == nil } + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } +} diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 0687979d..a0534c99 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -319,6 +319,9 @@ public final class WebInspectorFetchedResults @ObservationIgnored private var networkQueryPlan: NetworkRequestQueryPlan? @ObservationIgnored private var networkQueryState: NetworkRequestQueryState? @ObservationIgnored private var networkIndexSequence: UInt64 + @ObservationIgnored private var consoleQueryPlan: ConsoleMessageQueryPlan? + @ObservationIgnored private var consoleQueryState: ConsoleMessageQueryState? + @ObservationIgnored private var consoleIndexSequence: UInt64 /// The descriptor currently used by the results. public var fetchDescriptor: WebInspectorFetchDescriptor { @@ -374,6 +377,9 @@ public final class WebInspectorFetchedResults networkQueryPlan = nil networkQueryState = nil networkIndexSequence = 0 + consoleQueryPlan = nil + consoleQueryState = nil + consoleIndexSequence = 0 } deinit { @@ -432,6 +438,55 @@ public final class WebInspectorFetchedResults ) } + private func applyIndexedDelta( + sequence: UInt64, + snapshot: WebInspectorFetchedResultsSnapshot, + transaction: WebInspectorFetchedResultsTransaction, + reconfigureItemIDs: Set, + currentIndexSequence: UInt64, + lookup: (Model.ID) -> Model? + ) -> UInt64 { + guard sequence > currentIndexSequence else { + return currentIndexSequence + } + if snapshot == state.snapshot { + publish( + items: state.items, + sections: state.sections, + snapshot: snapshot, + transaction: transaction, + updatedItemIDs: reconfigureItemIDs + ) + return sequence + } + let items = snapshot.itemIDs.map { id in + guard let model = lookup(id) else { + preconditionFailure("An indexed fetched-results snapshot referenced an unregistered \(Model.self).") + } + return model + } + let sections = snapshot.sections.map { section in + WebInspectorFetchSection( + id: section.id, + title: section.title, + items: section.itemIDs.map { id in + guard let model = lookup(id) else { + preconditionFailure("An indexed fetched-results section referenced an unregistered \(Model.self).") + } + return model + } + ) + } + publish( + items: items, + sections: sections, + snapshot: snapshot, + transaction: transaction, + updatedItemIDs: reconfigureItemIDs + ) + return sequence + } + /// Replaces the fetch descriptor and updates the result contents. public func updateFetchDescriptor( _ descriptor: WebInspectorFetchDescriptor, @@ -667,45 +722,117 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { _ delta: NetworkResultSetDelta, lookup: (NetworkRequest.ID) -> NetworkRequest? ) { - guard delta.sequence > networkIndexSequence else { - return - } - if delta.snapshot == state.snapshot { - publish( - items: state.items, - sections: state.sections, - snapshot: delta.snapshot, - transaction: delta.transaction, - updatedItemIDs: delta.reconfigureItemIDs - ) - networkIndexSequence = delta.sequence - return + networkIndexSequence = applyIndexedDelta( + sequence: delta.sequence, + snapshot: delta.snapshot, + transaction: delta.transaction, + reconfigureItemIDs: delta.reconfigureItemIDs, + currentIndexSequence: networkIndexSequence, + lookup: lookup + ) + } +} + +extension WebInspectorFetchedResults where Model == ConsoleMessage { + var consoleSnapshotForDelta: WebInspectorFetchedResultsSnapshot { + state.snapshot + } + + var consoleIndexSequenceForDelta: UInt64 { + consoleIndexSequence + } + + func currentConsoleQueryPlan(context: WebInspectorContext) -> ConsoleMessageQueryPlan { + if let consoleQueryPlan { + return consoleQueryPlan } - let items = delta.snapshot.itemIDs.map { id in - guard let request = lookup(id) else { - preconditionFailure("A NetworkRequestIndex snapshot referenced an unregistered request.") - } - return request + let plan = ConsoleMessageQueryPlan(descriptor: state.fetchDescriptor, context: context) + consoleQueryPlan = plan + return plan + } + + func setConsoleItems( + _ messages: [ConsoleMessage], + plan: ConsoleMessageQueryPlan, + indexSequence: UInt64, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + consoleQueryPlan = plan + if plan.requiresQuery { + let queryState = ConsoleMessageQueryState(plan: plan, messages: messages) + consoleQueryState = queryState + setItems(queryState.visibleMessages(lookup: lookup)) + } else { + consoleQueryState = nil + setItems(messages) } - let sections = delta.snapshot.sections.map { section in - WebInspectorFetchSection( - id: section.id, - title: section.title, - items: section.itemIDs.map { id in - guard let request = lookup(id) else { - preconditionFailure("A NetworkRequestIndex section referenced an unregistered request.") - } - return request - } - ) + consoleIndexSequence = indexSequence + } + + func applyConsoleFetchDescriptor( + _ descriptor: WebInspectorFetchDescriptor, + plan: ConsoleMessageQueryPlan, + messages: [ConsoleMessage], + indexSequence: UInt64, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + consoleQueryPlan = plan + let visibleMessages: [ConsoleMessage] + if plan.requiresQuery { + let queryState = ConsoleMessageQueryState(plan: plan, messages: messages) + consoleQueryState = queryState + visibleMessages = queryState.visibleMessages(lookup: lookup) + } else { + consoleQueryState = nil + visibleMessages = messages } + let sections = Self.sections(for: visibleMessages, sectionBy: state.sectionBy) publish( - items: items, + items: visibleMessages, sections: sections, + fetchDescriptor: descriptor, + isReset: true + ) + consoleIndexSequence = indexSequence + } + + func insertConsoleMessage( + _ message: ConsoleMessage, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + guard var queryState = consoleQueryState else { + insertItem(message) + return + } + queryState.upsert(message: message) + consoleQueryState = queryState + setItems(queryState.visibleMessages(lookup: lookup)) + } + + func refreshConsoleMessageAfterMutation( + _ message: ConsoleMessage, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + guard var queryState = consoleQueryState else { + refreshAfterItemMutation(message) + return + } + queryState.upsert(message: message) + consoleQueryState = queryState + setItems(queryState.visibleMessages(lookup: lookup), updatedItemIDs: [message.id]) + } + + func applyConsoleDelta( + _ delta: ConsoleResultSetDelta, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + consoleIndexSequence = applyIndexedDelta( + sequence: delta.sequence, snapshot: delta.snapshot, transaction: delta.transaction, - updatedItemIDs: delta.reconfigureItemIDs + reconfigureItemIDs: delta.reconfigureItemIDs, + currentIndexSequence: consoleIndexSequence, + lookup: lookup ) - networkIndexSequence = delta.sequence } } diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 3d491177..8f27cd81 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -154,12 +154,7 @@ public final class WebInspectorContext { private var consoleTrackingTarget: WebInspectorTarget? private let statusRelay: WebInspectorAsyncStreamRelay private let networkRequests: NetworkRequestStore - 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 let consoleMessages: ConsoleMessageStore private var runtimeContextsByID: [RuntimeContext.ID: RuntimeContext] private var orderedRuntimeContextIDs: [RuntimeContext.ID] private var runtimeObjectsByID: [RuntimeObject.ID: RuntimeObject] @@ -203,12 +198,7 @@ public final class WebInspectorContext { consoleTrackingTarget = nil statusRelay = WebInspectorAsyncStreamRelay() networkRequests = NetworkRequestStore() - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] - nextConsoleMessageOrdinal = 0 - consoleFetchedResults = [] + consoleMessages = ConsoleMessageStore() runtimeContextsByID = [:] orderedRuntimeContextIDs = [] runtimeObjectsByID = [:] @@ -333,7 +323,7 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) -> ConsoleMessage? { requireOwner(isolation) - return consoleMessagesByID[id] + return consoleMessages.message(for: id, isolation: isolation) } /// Selects a DOM node and reveals it in registered tree controllers. @@ -769,8 +759,7 @@ public final class WebInspectorContext { 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)) + consoleMessages.register(consoleResults, modelContext: self, isolation: isolation) } return results } @@ -869,7 +858,12 @@ public final class WebInspectorContext { let consoleResults = results as? WebInspectorFetchedResults else { preconditionFailure("ConsoleMessage descriptors can only update ConsoleMessage fetched results.") } - consoleResults.applyFetchDescriptor(consoleDescriptor, items: consoleMessages(for: consoleDescriptor)) + consoleMessages.updateFetchDescriptor( + consoleDescriptor, + for: consoleResults, + modelContext: self, + isolation: isolation + ) } } @@ -1193,7 +1187,7 @@ public final class WebInspectorContext { guard isCurrentPageGeneration(generation, isolation: isolation) else { return } - resetReplayBackedModelsBeforeEnable() + resetReplayBackedModelsBeforeEnable(isolation: isolation) try await enableInspectorTracking(on: target, generation: generation, isolation: isolation) guard Task.isCancelled == false else { await disableEnabledDomainsAfterCancellation(isolation: isolation) @@ -1410,13 +1404,11 @@ public final class WebInspectorContext { return try await loadCurrentDOMDocument(on: target, isolation: isolation) } - private func resetReplayBackedModelsBeforeEnable() { + private func resetReplayBackedModelsBeforeEnable( + isolation: isolated (any Actor) + ) { clearExecutionContexts() - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] - refreshAllConsoleMessages() + consoleMessages.resetForReplay(modelContext: self, isolation: isolation) } private func resetNetworkModelsForNewAttachment( @@ -1428,7 +1420,7 @@ public final class WebInspectorContext { private func resetCurrentPageLifecycleModels(isolation: isolated (any Actor)) { resetDOM(isolation: isolation) clearExecutionContexts() - clearConsoleMessages() + clearConsoleMessages(isolation: isolation) } private func resetAttachmentBackedModels(isolation: isolated (any Actor)) { @@ -1535,7 +1527,7 @@ public final class WebInspectorContext { 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) + await self.apply(event.event, targetID: event.targetID, isolation: isolation) self.recordEventPumpAppliedForTesting() } @@ -2717,57 +2709,44 @@ extension WebInspectorContext { _ event: Console.Event, targetID: WebInspectorTarget.ID? = nil, isolation: isolated (any Actor) = #isolation - ) { + ) async { 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) + let effects = await consoleMessages.apply( + event, + targetID: targetID, + modelContext: self, + registerRuntimeObject: { [self] payload in + registerRuntimeObject(payload, owner: .console) + }, + isolation: isolation + ) + applyConsoleMessageEffects(effects) + switch effects.runtimeObjectGroupRelease { + case .currentPage: + releaseConsoleRuntimeObjectGroup(isolation: isolation) + case let .target(targetID): releaseConsoleRuntimeObjectGroup(targetID: targetID, isolation: isolation) - case .unknown: + case nil: break } } - private func clearConsoleMessages(targetID: WebInspectorTarget.ID? = nil) { - guard let targetID else { - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] + private func clearConsoleMessages(isolation: isolated (any Actor)) { + let effects = consoleMessages.clearForLifecycle( + modelContext: self, + isolation: isolation + ) + applyConsoleMessageEffects(effects) + } + + private func applyConsoleMessageEffects(_ effects: ConsoleMessageStore.Effects) { + if effects.clearedAllMessages { 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 + for object in effects.runtimeObjectsToUnregister { + unregisterRuntimeObject(object, owner: .console) } - unregisterConsoleRuntimeObjectsIfUnreferenced(from: removedMessages) - refreshAllConsoleMessages() } private func releaseConsoleRuntimeObjectGroup( @@ -2805,90 +2784,6 @@ extension WebInspectorContext { } 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 { diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift new file mode 100644 index 00000000..45aeb5ab --- /dev/null +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -0,0 +1,243 @@ +import Foundation +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +private actor StoreIsolationProbe { + func exercise() async -> (networkCount: Int, consoleEpoch: UInt64) { + let context = WebInspectorContext.preview(isolation: self) + 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, + isolation: self + ) + + let consoleStore = ConsoleMessageStore() + _ = 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.") }, + isolation: self + ) + return (networkStore.collectionState.requestCount, consoleStore.collectionEpoch) + } +} + +@Test +func networkAndConsoleStoresFollowTheCallingActor() async { + let values = await StoreIsolationProbe().exercise() + + #expect(values.networkCount == 1) + #expect(values.consoleEpoch == 1) +} + +@MainActor +@Test +func consoleMessageQueryPlanKeepsSupportedPredicatesOffTheModelActor() { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let descriptor = WebInspectorFetchDescriptor( + predicate: #Predicate { message in + message.level.rawValue == "warning" + }, + sortBy: [SortDescriptor(\.text, order: .reverse)], + fetchLimit: 100 + ) + + let plan = ConsoleMessageQueryPlan(descriptor: descriptor, context: context) + + #expect(plan.requiresQuery) + #expect(plan.requiresModelPredicate == false) +} + +@MainActor +@Test +func consoleMessageIndexDrainsMutationsInSequenceOrder() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let first = ConsoleMessage( + id: ConsoleMessage.ID(0), + message: Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "first" + ), + parameters: [], + targetID: nil, + modelContext: context + ) + let second = ConsoleMessage( + id: ConsoleMessage.ID(1), + message: Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "second" + ), + parameters: [], + targetID: nil, + modelContext: context + ) + let index = ConsoleMessageIndex() + + let secondMutation = Task { + await index.upsert( + ConsoleMessageRecordInput(message: second, orderIndex: 1), + sequence: 2 + ) + } + for _ in 0..<1_000 { + if await index.isMutationPendingForTesting(sequence: 2) { + break + } + await Task.yield() + } + let secondMutationIsPending = await index.isMutationPendingForTesting(sequence: 2) + #expect(secondMutationIsPending) + await index.upsert( + ConsoleMessageRecordInput(message: first, orderIndex: 0), + sequence: 1 + ) + await secondMutation.value + + let delta = await index.delta( + plan: ConsoleMessageQueryPlan( + descriptor: WebInspectorFetchDescriptor(), + context: context + ), + sectionBy: nil, + oldSnapshot: WebInspectorFetchedResultsSnapshot(), + changedSince: 0 + ) + #expect(delta.sequence == 2) + #expect(delta.snapshot.itemIDs == [first.id, second.id]) +} + +@MainActor +@Test +func consoleMessageStoreOwnsTargetOrderEpochAndClearEffects() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let firstTargetID = WebInspectorTarget.ID("first-target") + let secondTargetID = WebInspectorTarget.ID("second-target") + + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "first" + )), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "javascript"), + level: Console.Level(rawValue: "warning"), + text: "second" + )), + targetID: secondTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + _ = await store.apply( + .messageRepeatCountUpdated(count: 3, timestamp: 4), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) + + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + #expect(results.items.map(\.text) == ["first", "second"]) + #expect(results.items.first?.repeatCount == 3) + #expect(store.collectionEpoch == 2) + + let effects = await store.apply( + .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, + isolation: MainActor.shared + ) + + #expect(effects.removedMessages.map(\.text) == ["first"]) + #expect(effects.clearedAllMessages == false) + #expect(effects.runtimeObjectGroupRelease == .target(firstTargetID)) + #expect(results.items.map(\.text) == ["second"]) + #expect(store.collectionEpoch == 3) +} + +@MainActor +@Test +func consoleMessageStoreTenThousandLiveInsertsAndMutationUseCompactProjections() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let recordCount = 10_000 + + for ordinal in 0..( + fetchDescriptor: WebInspectorFetchDescriptor(), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + #expect(results.items.count == recordCount) + store.resetPerformanceCountersForTesting(isolation: MainActor.shared) + + _ = await store.apply( + .messageRepeatCountUpdated(count: 7, timestamp: Double(recordCount)), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) + + let mutationCounters = store.performanceCountersForTesting + #expect(mutationCounters.incrementalRecordProjectionCount == 1) + #expect(mutationCounters.fullRecordProjectionCount == 0) + #expect(mutationCounters.fullModelProjectionCount == 0) + #expect(mutationCounters.resultIdentityLookupCount == 0) + #expect(results.items.count == recordCount) + #expect(results.items.last?.repeatCount == 7) +} diff --git a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift index 75208c9e..0d49ef73 100644 --- a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift @@ -87,6 +87,12 @@ func networkRequestStorePropertyMutationAtTenThousandRecordsUsesOneCompactProjec ) } + let insertCounters = store.performanceCountersForTesting + #expect(insertCounters.incrementalRecordProjectionCount == recordCount) + #expect(insertCounters.fullRecordProjectionCount == 0) + #expect(insertCounters.fullModelProjectionCount == 0) + #expect(insertCounters.resultIdentityLookupCount == 0) + let results = WebInspectorFetchedResults( fetchDescriptor: WebInspectorFetchDescriptor(), modelContext: context diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index ba67d65c..79a7e2bb 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -1583,11 +1583,11 @@ func consoleMessagesClearedForDistinctTargetsReleasesBothObjectGroups() async th await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") - context.apply( + await context.apply( Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), targetID: WebInspectorTarget.ID("console-frame-a") ) - context.apply( + await context.apply( Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), targetID: WebInspectorTarget.ID("console-frame-b") ) @@ -6973,6 +6973,10 @@ func consoleMessagesClearedReleasesConsoleRuntimeObjects() async throws { await runtime.backend.recordedCommands() .contains(RecordedCommand(domain: "Runtime", method: "releaseObjectGroup")) } + let releaseObjectGroupCommands = await runtime.backend.recordedCommands().filter { + $0 == RecordedCommand(domain: "Runtime", method: "releaseObjectGroup") + } + #expect(releaseObjectGroupCommands.count == 1) try await waitUntil { results.items.isEmpty } do { _ = try await parameter.properties() From 62f3eacb7952227cd753ae453fb5b2d3e3ae4832 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:12:22 +0900 Subject: [PATCH 23/81] fix(data): preserve target-scoped Console state --- .../ConsoleMessageStore.swift | 17 +++---- .../ConsoleMessageStoreTests.swift | 50 +++++++++++++++---- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift index 066bf575..d3c769d7 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -12,7 +12,6 @@ package final class ConsoleMessageStore { } package struct Effects { - package var removedMessages: [ConsoleMessage] = [] package var runtimeObjectsToUnregister: [RuntimeObject] = [] package var clearedAllMessages = false package var runtimeObjectGroupRelease: RuntimeObjectGroupRelease? @@ -27,8 +26,6 @@ package final class ConsoleMessageStore { } #endif - package private(set) var collectionEpoch: UInt64 - private var messagesByID: [ConsoleMessage.ID: ConsoleMessage] private var orderedMessageIDs: [ConsoleMessage.ID] private var orderIndicesByID: [ConsoleMessage.ID: Int] @@ -44,7 +41,6 @@ package final class ConsoleMessageStore { #endif package init() { - collectionEpoch = 0 messagesByID = [:] orderedMessageIDs = [] orderIndicesByID = [:] @@ -158,10 +154,9 @@ package final class ConsoleMessageStore { modelContext: WebInspectorContext, isolation: isolated (any Actor) = #isolation ) -> Effects { - let removedMessages = removeMessages(targetID: nil, isolation: isolation) + _ = removeMessages(targetID: nil, isolation: isolation) refreshAllResults(modelContext: modelContext, isolation: isolation) return Effects( - removedMessages: removedMessages, clearedAllMessages: true ) } @@ -190,7 +185,6 @@ package final class ConsoleMessageStore { if let targetID { lastMessageIDByTargetID[targetID] = id } - collectionEpoch &+= 1 await notifyMessageInserted(message, modelContext: modelContext, isolation: isolation) } @@ -201,7 +195,12 @@ package final class ConsoleMessageStore { modelContext: WebInspectorContext, isolation: isolated (any Actor) ) async { - let candidateID = targetID.flatMap { lastMessageIDByTargetID[$0] } ?? lastMessageID + 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") @@ -225,7 +224,6 @@ package final class ConsoleMessageStore { ) refreshAllResults(modelContext: modelContext, isolation: isolation) return Effects( - removedMessages: removedMessages, runtimeObjectsToUnregister: runtimeObjectsToUnregister, clearedAllMessages: targetID == nil, runtimeObjectGroupRelease: targetID.map(RuntimeObjectGroupRelease.target) ?? .currentPage @@ -238,7 +236,6 @@ package final class ConsoleMessageStore { isolation: isolated (any Actor) ) -> [ConsoleMessage] { _ = isolation - collectionEpoch &+= 1 queryIndexNeedsRebuild = true guard let targetID else { let removedMessages = Array(messagesByID.values) diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index 45aeb5ab..9e2ccab5 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -4,7 +4,7 @@ import Testing import WebInspectorProxyKit private actor StoreIsolationProbe { - func exercise() async -> (networkCount: Int, consoleEpoch: UInt64) { + func exercise() async -> (networkCount: Int, consoleItemCount: Int) { let context = WebInspectorContext.preview(isolation: self) let networkStore = NetworkRequestStore() let networkID = Network.Request.ID("custom-actor-request") @@ -25,6 +25,11 @@ private actor StoreIsolationProbe { ) let consoleStore = ConsoleMessageStore() + let consoleResults = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + modelContext: context + ) + consoleStore.register(consoleResults, modelContext: context, isolation: self) _ = await consoleStore.apply( .messageAdded(Console.Message( source: Console.Source(rawValue: "console-api"), @@ -36,7 +41,7 @@ private actor StoreIsolationProbe { registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, isolation: self ) - return (networkStore.collectionState.requestCount, consoleStore.collectionEpoch) + return (networkStore.collectionState.requestCount, consoleResults.items.count) } } @@ -45,7 +50,7 @@ func networkAndConsoleStoresFollowTheCallingActor() async { let values = await StoreIsolationProbe().exercise() #expect(values.networkCount == 1) - #expect(values.consoleEpoch == 1) + #expect(values.consoleItemCount == 1) } @MainActor @@ -129,7 +134,7 @@ func consoleMessageIndexDrainsMutationsInSequenceOrder() async throws { @MainActor @Test -func consoleMessageStoreOwnsTargetOrderEpochAndClearEffects() async throws { +func consoleMessageStoreOwnsTargetOrderAndClearEffects() async throws { let context = WebInspectorContext.preview(isolation: MainActor.shared) let store = ConsoleMessageStore() let firstTargetID = WebInspectorTarget.ID("first-target") @@ -157,6 +162,17 @@ func consoleMessageStoreOwnsTargetOrderEpochAndClearEffects() async throws { registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, isolation: MainActor.shared ) + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "page" + )), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) _ = await store.apply( .messageRepeatCountUpdated(count: 3, timestamp: 4), targetID: firstTargetID, @@ -164,15 +180,29 @@ func consoleMessageStoreOwnsTargetOrderEpochAndClearEffects() async throws { registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, isolation: MainActor.shared ) + _ = await store.apply( + .messageRepeatCountUpdated(count: 9, timestamp: 5), + targetID: WebInspectorTarget.ID("unknown-target"), + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) + _ = await store.apply( + .messageRepeatCountUpdated(count: 4, timestamp: 6), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) let results = WebInspectorFetchedResults( fetchDescriptor: WebInspectorFetchDescriptor(), modelContext: context ) store.register(results, modelContext: context, isolation: MainActor.shared) - #expect(results.items.map(\.text) == ["first", "second"]) - #expect(results.items.first?.repeatCount == 3) - #expect(store.collectionEpoch == 2) + #expect(results.items.map(\.text) == ["first", "second", "page"]) + #expect(results.items.map(\.repeatCount) == [3, 1, 4]) + let firstMessageID = try #require(results.items.first?.id) let effects = await store.apply( .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), @@ -182,11 +212,11 @@ func consoleMessageStoreOwnsTargetOrderEpochAndClearEffects() async throws { isolation: MainActor.shared ) - #expect(effects.removedMessages.map(\.text) == ["first"]) #expect(effects.clearedAllMessages == false) #expect(effects.runtimeObjectGroupRelease == .target(firstTargetID)) - #expect(results.items.map(\.text) == ["second"]) - #expect(store.collectionEpoch == 3) + #expect(results.items.map(\.text) == ["second", "page"]) + let clearedMessage = store.message(for: firstMessageID, isolation: MainActor.shared) + #expect(clearedMessage == nil) } @MainActor From cbf0ae6e00b3469efdbe5b1a0e1fa9bad79ec10d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:14:34 +0900 Subject: [PATCH 24/81] refactor(data): share fetched-results transaction building --- .../NetworkRequestIndex.swift | 256 +----------------- ...ebInspectorFetchedResultsPublication.swift | 170 +++++++++--- .../ConsoleMessageStoreTests.swift | 249 +++++++++++++++++ 3 files changed, 378 insertions(+), 297 deletions(-) diff --git a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index b6623829..7793fd3b 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -108,7 +108,7 @@ package actor NetworkRequestIndex { let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence }) - let transaction = NetworkResultSetTransactionBuilder.transaction( + let transaction = WebInspectorFetchedResultsTransaction( oldSnapshot: oldSnapshot, newSnapshot: newSnapshot, updatedItemIDs: updatedItemIDs @@ -219,257 +219,3 @@ package actor NetworkRequestIndex { return (WebInspectorFetchSectionID(rawValue: title), title) } } - -private enum NetworkResultSetTransactionBuilder { - typealias Snapshot = WebInspectorFetchedResultsSnapshot - typealias ItemID = NetworkRequest.ID - - static func transaction( - oldSnapshot: Snapshot, - newSnapshot: Snapshot, - updatedItemIDs: Set - ) -> WebInspectorFetchedResultsTransaction { - let sectionChanges = sectionChanges(from: oldSnapshot, to: newSnapshot) - let itemChanges = itemChanges( - from: oldSnapshot, - to: newSnapshot, - updatedItemIDs: updatedItemIDs - ) - return WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - isReset: false, - sectionChanges: sectionChanges, - itemChanges: itemChanges - ) - } - - 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 - } - 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 itemChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot, - 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: 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 static func moveChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot, - 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 static func indexSections( - _ sections: [Snapshot.Section] - ) -> [WebInspectorFetchSectionID: Int] { - Dictionary( - uniqueKeysWithValues: sections.enumerated().map { index, section in - (section.id, index) - } - ) - } - - private struct ItemPosition { - var itemID: ItemID - var sectionID: WebInspectorFetchSectionID - var indexPath: WebInspectorFetchedResultsIndexPath - } - - 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 - ) - ) - } - } - 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 - } - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift index ac53c8a9..5c2bce9c 100644 --- a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift +++ b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift @@ -191,13 +191,19 @@ public struct WebInspectorFetchedResultsTransaction 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 + 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) } - return .move(sectionID: section.id, from: oldIndex, to: newIndex) - } + } let updates = newSnapshot.sections.enumerated() .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in @@ -251,37 +257,118 @@ public struct WebInspectorFetchedResultsTransaction ) } - let moves = newPositions.values - .compactMap { newPosition -> WebInspectorFetchedResultsItemChange? in - guard let oldPosition = oldPositions[newPosition.itemID], - oldPosition.indexPath != newPosition.indexPath else { - return nil - } + 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: newPosition.itemID, + itemID: 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) + 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 } - .sorted { lhs, rhs in - lhs.newIndexPathForOrdering < rhs.newIndexPathForOrdering + guard let oldPosition = oldPositions[itemID], + let newPosition = newPositions[itemID], + oldPosition.sectionID == newPosition.sectionID, + oldPosition.indexPath != newPosition.indexPath else { + return nil } - - return deletes + inserts + moves + updates + return .move( + itemID: itemID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ) + } } private struct ItemPosition { @@ -308,6 +395,18 @@ public struct WebInspectorFetchedResultsTransaction } 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. @@ -554,19 +653,6 @@ final class WebInspectorFetchedResultsUpdateBroker: } } -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 < ( diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index 9e2ccab5..f6034c8f 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -219,6 +219,255 @@ func consoleMessageStoreOwnsTargetOrderAndClearEffects() async throws { #expect(clearedMessage == nil) } +@MainActor +@Test +func consoleMessageTransactionsPreserveInsertMoveAndUpdateSemantics() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let firstTargetID = WebInspectorTarget.ID("sorted-first-target") + let secondTargetID = WebInspectorTarget.ID("sorted-second-target") + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor( + sortBy: [SortDescriptor(\.repeatCount, order: .reverse)] + ), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + var updates = results.updates().makeAsyncIterator() + guard case .initial? = await updates.next() else { + Issue.record("Expected the initial Console fetched-results snapshot.") + return + } + + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "first", + repeatCount: 1 + )), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + let firstID = try #require(results.items.first?.id) + guard case let .transaction(_, firstInsert, firstReconfigure)? = await updates.next() else { + Issue.record("Expected the first Console insertion transaction.") + return + } + #expect(firstInsert.itemChanges == [ + .insert( + itemID: firstID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) + #expect(firstReconfigure.isEmpty) + + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "second", + repeatCount: 2 + )), + targetID: secondTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + let secondID = try #require(results.items.first?.id) + guard case let .transaction(_, frontInsert, frontReconfigure)? = await updates.next() else { + Issue.record("Expected the front insertion transaction.") + return + } + #expect(results.items.map(\.id) == [secondID, firstID]) + #expect(frontInsert.itemChanges == [ + .insert( + itemID: secondID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) + #expect(frontReconfigure.isEmpty) + + _ = await store.apply( + .messageRepeatCountUpdated(count: 3, timestamp: 3), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) + guard case let .transaction(_, move, moveReconfigure)? = await updates.next() else { + Issue.record("Expected the Console sort-move transaction.") + return + } + #expect(results.items.map(\.id) == [firstID, secondID]) + #expect(move.itemChanges == [ + .move( + itemID: firstID, + from: WebInspectorFetchedResultsIndexPath(section: 0, item: 1), + to: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) + #expect(moveReconfigure == [firstID]) + + _ = await store.apply( + .messageRepeatCountUpdated(count: 4, timestamp: 4), + targetID: firstTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + isolation: MainActor.shared + ) + guard case let .transaction(_, update, updateReconfigure)? = await updates.next() else { + Issue.record("Expected the in-place Console update transaction.") + return + } + #expect(update.itemChanges == [ + .update( + itemID: firstID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) + #expect(updateReconfigure == [firstID]) +} + +@MainActor +@Test +func consoleMessageTransactionsPreserveSectionInsertionAndDeletion() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let warningTargetID = WebInspectorTarget.ID("section-warning-target") + let errorTargetID = WebInspectorTarget.ID("section-error-target") + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + sectionBy: WebInspectorSectionDescriptor(\.level), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + var updates = results.updates().makeAsyncIterator() + guard case .initial? = await updates.next() else { + Issue.record("Expected the initial sectioned Console snapshot.") + return + } + + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "warning"), + text: "warning" + )), + targetID: warningTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + let warningID = try #require(results.items.first?.id) + guard case let .transaction(_, warningInsert, _)? = await updates.next() else { + Issue.record("Expected the warning-section insertion transaction.") + return + } + #expect(warningInsert.sectionChanges == [ + .insert(sectionID: WebInspectorFetchSectionID(rawValue: "warning"), index: 0), + ]) + #expect(warningInsert.itemChanges == [ + .insert( + itemID: warningID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) + + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "javascript"), + level: Console.Level(rawValue: "error"), + text: "error" + )), + targetID: errorTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + let errorID = try #require(results.items.last?.id) + guard case let .transaction(_, errorInsert, _)? = await updates.next() else { + Issue.record("Expected the error-section insertion transaction.") + return + } + #expect(errorInsert.sectionChanges == [ + .insert(sectionID: WebInspectorFetchSectionID(rawValue: "error"), index: 1), + ]) + #expect(errorInsert.itemChanges == [ + .insert( + itemID: errorID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 1, item: 0) + ), + ]) + + _ = await store.apply( + .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: warningTargetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, + isolation: MainActor.shared + ) + guard case let .transaction(_, warningDelete, _)? = await updates.next() else { + Issue.record("Expected the warning-section deletion transaction.") + return + } + #expect(results.items.map(\.id) == [errorID]) + #expect(warningDelete.sectionChanges == [ + .delete(sectionID: WebInspectorFetchSectionID(rawValue: "warning"), index: 0), + ]) + #expect(warningDelete.itemChanges == [ + .delete( + itemID: warningID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) + ), + ]) +} + +@MainActor +@Test +func consoleMessageTransactionPublishesMembershipChangeFromDeletedSection() { + let firstID = ConsoleMessage.ID(0) + let secondID = ConsoleMessage.ID(1) + let warningSection = WebInspectorFetchSectionID(rawValue: "warning") + let errorSection = WebInspectorFetchSectionID(rawValue: "error") + let oldSnapshot = WebInspectorFetchedResultsSnapshot(sections: [ + WebInspectorFetchedResultsSnapshot.Section( + id: warningSection, + title: "warning", + itemIDs: [firstID] + ), + WebInspectorFetchedResultsSnapshot.Section( + id: errorSection, + title: "error", + itemIDs: [secondID] + ), + ]) + let newSnapshot = WebInspectorFetchedResultsSnapshot(sections: [ + WebInspectorFetchedResultsSnapshot.Section( + id: warningSection, + title: "warning", + itemIDs: [firstID, secondID] + ), + ]) + + let transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot + ) + + #expect(transaction.sectionChanges == [ + .delete(sectionID: errorSection, index: 1), + ]) + #expect(transaction.itemChanges == [ + .insert( + itemID: secondID, + indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1) + ), + ]) +} + @MainActor @Test func consoleMessageStoreTenThousandLiveInsertsAndMutationUseCompactProjections() async throws { From 2c806463c8bc3944d061815e5b86515e3688aaa5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:16:50 +0900 Subject: [PATCH 25/81] fix(data): preserve Console sort tie order --- .../ConsoleMessageQuery.swift | 7 ----- .../ConsoleMessageStoreTests.swift | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift index 23e11c26..06927430 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift @@ -149,9 +149,6 @@ package struct ConsoleMessageQueryPlan: Sendable { continue } } - if sortComparators.first?.usesReverseOrder == true { - return lhs.orderIndex > rhs.orderIndex - } return lhs.orderIndex < rhs.orderIndex } } @@ -278,10 +275,6 @@ package struct ConsoleMessageRecordSortComparator: Sendable { } } - fileprivate var usesReverseOrder: Bool { - order == .reverse - } - private static func key( for descriptor: SortDescriptor, context: WebInspectorContext diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index f6034c8f..cf08eb64 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -331,6 +331,37 @@ func consoleMessageTransactionsPreserveInsertMoveAndUpdateSemantics() async thro #expect(updateReconfigure == [firstID]) } +@MainActor +@Test +func consoleMessageReverseSortPreservesInsertionOrderForEqualKeys() async { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor( + sortBy: [SortDescriptor(\.level.rawValue, order: .reverse)], + fetchLimit: 1 + ), + modelContext: context + ) + store.register(results, modelContext: context, isolation: MainActor.shared) + + for text in ["first", "second"] { + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: text + )), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + } + + #expect(results.items.map(\.text) == ["first"]) +} + @MainActor @Test func consoleMessageTransactionsPreserveSectionInsertionAndDeletion() async throws { From bdcba6351677428f30191b6d14bfdaaf48a940b7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:32:46 +0900 Subject: [PATCH 26/81] fix(data): preserve custom Console sorting --- .../ConsoleMessageIndex.swift | 2 +- .../ConsoleMessageQuery.swift | 176 ++++++++++-------- .../ConsoleMessageStore.swift | 10 +- Sources/WebInspectorDataKit/Fetching.swift | 4 +- .../ConsoleMessageStoreTests.swift | 57 +++++- 5 files changed, 156 insertions(+), 93 deletions(-) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift index efaf994f..10650396 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -102,7 +102,7 @@ package actor ConsoleMessageIndex { oldSnapshot: WebInspectorFetchedResultsSnapshot, changedSince sequence: UInt64 ) -> ConsoleResultSetDelta { - precondition(plan.requiresModelPredicate == false) + precondition(plan.requiresModelQuery == false) let indexSequence = lastAppliedSequence let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) let oldItemIDs = Set(oldSnapshot.itemIDs) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift index 06927430..6d449ce1 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift @@ -93,23 +93,32 @@ package struct ConsoleMessageQueryPlan: Sendable { package var filter: Filter? package var sortComparators: [ConsoleMessageRecordSortComparator] + package var modelSortDescriptors: [SortDescriptor]? package var fetchLimit: Int? package var fetchOffset: Int - package init( - descriptor: WebInspectorFetchDescriptor, - context: WebInspectorContext - ) { + package init(descriptor: WebInspectorFetchDescriptor) { filter = descriptor.predicate.map(makeConsoleMessageFilter) - sortComparators = descriptor.sortBy.map { - ConsoleMessageRecordSortComparator(descriptor: $0, context: context) + let recordSortComparators = descriptor.sortBy.compactMap { + ConsoleMessageRecordSortComparator(descriptor: $0) + } + if recordSortComparators.count == descriptor.sortBy.count { + sortComparators = recordSortComparators + modelSortDescriptors = nil + } else { + sortComparators = [] + modelSortDescriptors = descriptor.sortBy } fetchLimit = descriptor.fetchLimit fetchOffset = descriptor.fetchOffset } package var requiresQuery: Bool { - filter != nil || sortComparators.isEmpty == false || fetchLimit != nil || fetchOffset > 0 + filter != nil + || sortComparators.isEmpty == false + || modelSortDescriptors?.isEmpty == false + || fetchLimit != nil + || fetchOffset > 0 } package var requiresModelPredicate: Bool { @@ -119,6 +128,10 @@ package struct ConsoleMessageQueryPlan: Sendable { return true } + package var requiresModelQuery: Bool { + requiresModelPredicate || modelSortDescriptors != nil + } + package func matches(record: ConsoleMessageRecord) -> Bool? { filter?.matches(record: record) ?? true } @@ -139,6 +152,7 @@ package struct ConsoleMessageQueryPlan: Sendable { } package func ordersBefore(_ lhs: ConsoleMessageRecord, _ rhs: ConsoleMessageRecord) -> Bool { + precondition(modelSortDescriptors == nil) for comparator in sortComparators { switch comparator.compare(lhs, rhs) { case .orderedAscending: @@ -151,17 +165,42 @@ package struct ConsoleMessageQueryPlan: Sendable { } return lhs.orderIndex < rhs.orderIndex } + + package func ordersBefore( + _ lhs: ConsoleMessageRecord, + message lhsMessage: ConsoleMessage, + _ rhs: ConsoleMessageRecord, + message rhsMessage: ConsoleMessage + ) -> Bool { + guard let modelSortDescriptors else { + return ordersBefore(lhs, rhs) + } + for descriptor in modelSortDescriptors { + switch descriptor.compare(lhsMessage, rhsMessage) { + case .orderedAscending: + return true + case .orderedDescending: + return false + case .orderedSame: + continue + } + } + return lhs.orderIndex < rhs.orderIndex + } } package struct ConsoleMessageQueryState { package var plan: ConsoleMessageQueryPlan private var recordsByID: [ConsoleMessage.ID: ConsoleMessageRecord] + private var messagesByID: [ConsoleMessage.ID: ConsoleMessage] private var matchingIDs: [ConsoleMessage.ID] package init(plan: ConsoleMessageQueryPlan, messages: [ConsoleMessage]) { self.plan = plan recordsByID = [:] recordsByID.reserveCapacity(messages.count) + messagesByID = [:] + messagesByID.reserveCapacity(messages.count) matchingIDs = [] matchingIDs.reserveCapacity(messages.count) for (index, message) in messages.enumerated() { @@ -172,6 +211,7 @@ package struct ConsoleMessageQueryState { private mutating func upsert(message: ConsoleMessage, orderIndex: Int) { let record = ConsoleMessageRecord(message: message, orderIndex: orderIndex) recordsByID[record.id] = record + messagesByID[record.id] = message matchingIDs.removeAll { $0 == record.id } guard plan.matches(record: record, message: message) else { return @@ -191,18 +231,24 @@ package struct ConsoleMessageQueryState { } private mutating func insertMatchingID(_ id: ConsoleMessage.ID) { - guard let record = recordsByID[id] else { - return + guard let record = recordsByID[id], let message = messagesByID[id] else { + preconditionFailure("Console query state lost the record or model for an inserted identity.") } 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 + let midpointID = matchingIDs[midpoint] + guard let midpointRecord = recordsByID[midpointID], + let midpointMessage = messagesByID[midpointID] else { + preconditionFailure("Console query state lost a matching record or model.") } - if plan.ordersBefore(midpointRecord, record) { + if plan.ordersBefore( + midpointRecord, + message: midpointMessage, + record, + message: message + ) { lowerBound = midpoint + 1 } else { upperBound = midpoint @@ -229,12 +275,9 @@ package struct ConsoleMessageRecordSortComparator: Sendable { 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 ConsoleMessage sort descriptor: \(descriptor)") + fileprivate init?(descriptor: SortDescriptor) { + guard let key = Self.key(for: descriptor) else { + return nil } self.key = key order = descriptor.order @@ -247,15 +290,15 @@ package struct ConsoleMessageRecordSortComparator: Sendable { let result: ComparisonResult switch key { case .source: - result = compareValues(lhs.sourceRawValue, rhs.sourceRawValue) + result = compareStrings(lhs.sourceRawValue, rhs.sourceRawValue) case .level: - result = compareValues(lhs.levelRawValue, rhs.levelRawValue) + result = compareStrings(lhs.levelRawValue, rhs.levelRawValue) case .kind: - result = compareOptional(lhs.kindRawValue, rhs.kindRawValue) + result = compareOptionalStrings(lhs.kindRawValue, rhs.kindRawValue) case .text: - result = compareValues(lhs.text, rhs.text) + result = compareStrings(lhs.text, rhs.text) case .url: - result = compareOptional(lhs.url, rhs.url) + result = compareOptionalStrings(lhs.url, rhs.url) case .line: result = compareOptional(lhs.line, rhs.line) case .column: @@ -275,66 +318,41 @@ package struct ConsoleMessageRecordSortComparator: Sendable { } } - private static func key( - for descriptor: SortDescriptor, - context: WebInspectorContext - ) -> Key? { - let candidates: [(Key, Console.Message, Console.Message, ConsoleMessage.ID, ConsoleMessage.ID)] = [ - (.source, payload(source: "a"), payload(source: "b"), .init(0), .init(0)), - (.level, payload(level: "a"), payload(level: "b"), .init(0), .init(0)), - (.kind, payload(kind: "a"), payload(kind: "b"), .init(0), .init(0)), - (.text, payload(text: "a"), payload(text: "b"), .init(0), .init(0)), - (.url, payload(url: "a"), payload(url: "b"), .init(0), .init(0)), - (.line, payload(line: 1), payload(line: 2), .init(0), .init(0)), - (.column, payload(column: 1), payload(column: 2), .init(0), .init(0)), - (.repeatCount, payload(repeatCount: 1), payload(repeatCount: 2), .init(0), .init(0)), - (.timestamp, payload(timestamp: 1), payload(timestamp: 2), .init(0), .init(0)), - (.id, payload(), payload(), .init(1), .init(2)), + private static func key(for descriptor: SortDescriptor) -> Key? { + let order = descriptor.order + let candidates: [(Key, SortDescriptor)] = [ + (.source, SortDescriptor(\.source.rawValue, order: order)), + (.level, SortDescriptor(\.level.rawValue, order: order)), + (.kind, SortDescriptor(\.kind?.rawValue, order: order)), + (.text, SortDescriptor(\.text, order: order)), + (.url, SortDescriptor(\.url, order: order)), + (.line, SortDescriptor(\.line, order: order)), + (.column, SortDescriptor(\.column, order: order)), + (.repeatCount, SortDescriptor(\.repeatCount, order: order)), + (.timestamp, SortDescriptor(\.timestamp, order: order)), + (.id, SortDescriptor(\.id, order: order)), ] - for (key, lhsPayload, rhsPayload, lhsID, rhsID) in candidates { - let lhs = ConsoleMessage( - id: lhsID, - message: lhsPayload, - parameters: [], - targetID: nil, - modelContext: context - ) - let rhs = ConsoleMessage( - id: rhsID, - message: rhsPayload, - parameters: [], - targetID: nil, - modelContext: context - ) - if descriptor.compare(lhs, rhs) != .orderedSame { - return key - } + for (key, candidate) in candidates where descriptor == candidate { + return key } return nil } - private static func payload( - source: String = "source", - level: String = "level", - kind: String? = nil, - text: String = "text", - url: String? = nil, - line: Int? = nil, - column: Int? = nil, - repeatCount: Int = 1, - timestamp: Double? = nil - ) -> Console.Message { - Console.Message( - source: Console.Source(rawValue: source), - level: Console.Level(rawValue: level), - type: kind.map(Console.Kind.init(rawValue:)), - text: text, - url: url, - line: line, - column: column, - repeatCount: repeatCount, - timestamp: timestamp - ) + private func compareStrings(_ lhs: String, _ rhs: String) -> ComparisonResult { + String.StandardComparator.localizedStandard.compare(lhs, rhs) + } + + private func compareOptionalStrings(_ lhs: String?, _ rhs: String?) -> ComparisonResult { + switch (lhs, rhs) { + case (nil, nil): + return .orderedSame + case (nil, _): + return .orderedAscending + case (_, nil): + return .orderedDescending + case let (lhs?, rhs?): + return compareStrings(lhs, rhs) + } } private func compareValues(_ lhs: Value, _ rhs: Value) -> ComparisonResult { diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift index d3c769d7..93c45cb7 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -82,7 +82,7 @@ package final class ConsoleMessageStore { modelContext: WebInspectorContext, isolation: isolated (any Actor) = #isolation ) { - let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) + let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor) results.setConsoleItems( currentMessages(isolation: isolation), plan: plan, @@ -98,7 +98,7 @@ package final class ConsoleMessageStore { modelContext: WebInspectorContext, isolation: isolated (any Actor) = #isolation ) { - let plan = ConsoleMessageQueryPlan(descriptor: descriptor, context: modelContext) + let plan = ConsoleMessageQueryPlan(descriptor: descriptor) results.applyConsoleFetchDescriptor( descriptor, plan: plan, @@ -412,8 +412,8 @@ package final class ConsoleMessageStore { guard let results = registration.value else { continue } - let plan = results.currentConsoleQueryPlan(context: modelContext) - if plan.requiresModelPredicate { + let plan = results.currentConsoleQueryPlan() + if plan.requiresModelQuery { if inserted { results.insertConsoleMessage( message, @@ -459,7 +459,7 @@ package final class ConsoleMessageStore { guard let results = registration.value else { continue } - let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) + let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor) results.setConsoleItems( messages, plan: plan, diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index a0534c99..7abbd114 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -742,11 +742,11 @@ extension WebInspectorFetchedResults where Model == ConsoleMessage { consoleIndexSequence } - func currentConsoleQueryPlan(context: WebInspectorContext) -> ConsoleMessageQueryPlan { + func currentConsoleQueryPlan() -> ConsoleMessageQueryPlan { if let consoleQueryPlan { return consoleQueryPlan } - let plan = ConsoleMessageQueryPlan(descriptor: state.fetchDescriptor, context: context) + let plan = ConsoleMessageQueryPlan(descriptor: state.fetchDescriptor) consoleQueryPlan = plan return plan } diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index cf08eb64..07b0b9f9 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -56,7 +56,6 @@ func networkAndConsoleStoresFollowTheCallingActor() async { @MainActor @Test func consoleMessageQueryPlanKeepsSupportedPredicatesOffTheModelActor() { - let context = WebInspectorContext.preview(isolation: MainActor.shared) let descriptor = WebInspectorFetchDescriptor( predicate: #Predicate { message in message.level.rawValue == "warning" @@ -65,10 +64,59 @@ func consoleMessageQueryPlanKeepsSupportedPredicatesOffTheModelActor() { fetchLimit: 100 ) - let plan = ConsoleMessageQueryPlan(descriptor: descriptor, context: context) + let plan = ConsoleMessageQueryPlan(descriptor: descriptor) #expect(plan.requiresQuery) #expect(plan.requiresModelPredicate == false) + #expect(plan.requiresModelQuery == false) +} + +@MainActor +@Test +func consoleMessageSortPlanningPreservesStandardAndCustomComparators() async { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let customDescriptor = WebInspectorFetchDescriptor( + sortBy: [SortDescriptor(\.text, comparator: .lexical)] + ) + let customPlan = ConsoleMessageQueryPlan(descriptor: customDescriptor) + #expect(customPlan.requiresModelQuery) + #expect(customPlan.sortComparators.isEmpty) + #expect(customPlan.modelSortDescriptors?.count == 1) + let standardDescriptor = WebInspectorFetchDescriptor( + sortBy: [SortDescriptor(\.text)] + ) + let standardPlan = ConsoleMessageQueryPlan(descriptor: standardDescriptor) + #expect(standardPlan.requiresModelQuery == false) + #expect(standardPlan.sortComparators.count == 1) + #expect(standardPlan.modelSortDescriptors == nil) + + let customResults = WebInspectorFetchedResults( + fetchDescriptor: customDescriptor, + modelContext: context + ) + let standardResults = WebInspectorFetchedResults( + fetchDescriptor: standardDescriptor, + modelContext: context + ) + store.register(customResults, modelContext: context, isolation: MainActor.shared) + store.register(standardResults, modelContext: context, isolation: MainActor.shared) + for text in ["item2", "item10"] { + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: text + )), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + isolation: MainActor.shared + ) + } + + #expect(customResults.items.map(\.text) == ["item10", "item2"]) + #expect(standardResults.items.map(\.text) == ["item2", "item10"]) } @MainActor @@ -120,10 +168,7 @@ func consoleMessageIndexDrainsMutationsInSequenceOrder() async throws { await secondMutation.value let delta = await index.delta( - plan: ConsoleMessageQueryPlan( - descriptor: WebInspectorFetchDescriptor(), - context: context - ), + plan: ConsoleMessageQueryPlan(descriptor: WebInspectorFetchDescriptor()), sectionBy: nil, oldSnapshot: WebInspectorFetchedResultsSnapshot(), changedSince: 0 From 7ea5ff4162394b9cc5a391003f97263f4d8b0338 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:33:09 +0900 Subject: [PATCH 27/81] fix(data): trap fetched-results revision overflow --- Sources/WebInspectorDataKit/Fetching.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 7abbd114..279a8119 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -539,11 +539,20 @@ public final class WebInspectorFetchedResults ) } - let revision = oldState.revision &+ 1 - let topologyRevision = if isReset || oldState.snapshot != newSnapshot { - oldState.topologyRevision &+ 1 + precondition( + oldState.revision < UInt64.max, + "WebInspectorFetchedResults publication revision overflowed." + ) + let revision = oldState.revision + 1 + let topologyRevision: UInt64 + if isReset || oldState.snapshot != newSnapshot { + precondition( + oldState.topologyRevision < UInt64.max, + "WebInspectorFetchedResults topology revision overflowed." + ) + topologyRevision = oldState.topologyRevision + 1 } else { - oldState.topologyRevision + topologyRevision = oldState.topologyRevision } state = State( fetchDescriptor: fetchDescriptor ?? oldState.fetchDescriptor, From 1454437ca324b18f1e9390303e8ade7fc9311819 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:53:12 +0900 Subject: [PATCH 28/81] feat(proxy): acquire model feed capabilities transactionally --- Docs/WebInspectorKitsArchitecture.md | 56 +- .../ConnectionEventScopeRegistry.swift | 27 +- .../Transport/ConnectionModelFeed.swift | 29 + .../Transport/TransportSession.swift | 445 +++++++++--- .../ConnectionModelFeedTests.swift | 639 +++++++++++++++++- 5 files changed, 1058 insertions(+), 138 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 32b44291..588d9de9 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -603,15 +603,21 @@ package enum ModelBootstrapSnapshot: Sendable { /* target + epoch + typed snapsh package enum ModelDomain: Hashable, Sendable { /* configured domains */ } ``` -The initial transport slice implements the bounded exclusive feed, initial -`reset`/`targetSnapshot`, future target lifecycle deltas, and future events for -configured domains. For an empty configured-domain set it also emits -`synchronizationComplete`. Capability leases, replay-complete boundaries, DOM -bootstrap, configured-domain synchronization, and the full retarget sequence -described below remain later slices; their record cases define the package -contract but are not yet emitted. This distinction prevents schema availability -from being mistaken for readiness behavior that the transport does not yet -provide. +The current transport slice implements the bounded exclusive feed, initial +`reset`/`targetSnapshot`, future target lifecycle deltas, future events for +configured domains, and transactional capability leases. Acquisition follows +the deterministic domain order DOM, CSS, Network, Console, Runtime; CSS shares +the DOM prerequisite, while DOM's capability is local and sends no wire command. +The feed is registered and its initial records are published before the first +capability await, and `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`. +Replay-complete boundaries, DOM bootstrap, configured-domain synchronization, +and the complete ordered retarget readiness sequence described below remain +later slices; their record cases define the package contract but are not yet +emitted. This distinction prevents active capabilities from being mistaken for +model readiness behavior that the transport does not yet provide. The target snapshot contains the physical current page first, followed by its relevant committed frame targets in deterministic parent-before-child order. @@ -629,8 +635,12 @@ 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 and -releases the claim only after a clean close, allowing a replacement feed. +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; @@ -653,13 +663,14 @@ 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 completed design opens the feed and registers its initial reset record -before acquiring any configured capability. Capability acquisition will be -transactional: if one enable fails, the core releases every capability acquired -for that attempt, terminates the feed with the cause, and lets DataKit fail -attachment. 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. +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. The completed DOM readiness slice will use the same ordered feed even though DOM has no enable-time replay. After the feed is registered, the core will issue @@ -714,10 +725,11 @@ 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 -will eventually perform the following full ordered transition. The current -slice establishes only its synchronous reset, physical target snapshot, and -future-delta prefix: +`pageUnavailable`; it does not guess a stale target. During commit the core will +eventually perform the following full ordered transition. The current slice +establishes its synchronous reset, physical target snapshot, future-delta +prefix, and capability-owner reconciliation onto the new physical target; +replay/bootstrap watermarks and binding readiness remain later slices: 1. stop admission of new target-scoped commands and increment page generation; 2. publish `.reset(newGeneration)` to public scopes and the package model feed; diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift index f71087ab..f5ad61c6 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift @@ -6,6 +6,11 @@ struct ConnectionCapabilityKey: Hashable, Sendable { var domain: WebInspectorProxyEventDomain } +enum ConnectionCapabilityLeaseOwner: Hashable, Sendable { + case eventScope(WebInspectorProxyEventScopeID) + case modelFeed(ConnectionModelFeedID, ModelDomain) +} + struct ConnectionEventScopeRegistry { struct Entry: Sendable { var sink: WebInspectorEventSink? @@ -146,22 +151,22 @@ struct ConnectionCapabilityRegistry { struct State: Sendable { var physical: PhysicalState - var leaseIDs: Set = [] - var failedLeaseIDs: Set = [] - var activatedLeaseIDs: Set = [] - var activationWaiters: [WebInspectorProxyEventScopeID: ReplyPromise] = [:] - var releaseWaiters: [WebInspectorProxyEventScopeID: ReplyPromise] = [:] - - var desiredLeaseIDs: Set { - leaseIDs.subtracting(failedLeaseIDs) + 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 { - desiredLeaseIDs.count + desiredLeaseOwners.count } var hasActivatedDesiredLease: Bool { - !activatedLeaseIDs.intersection(desiredLeaseIDs).isEmpty + !activatedLeaseOwners.intersection(desiredLeaseOwners).isEmpty } } @@ -175,7 +180,7 @@ struct ConnectionCapabilityRegistry { mutating func removeEmptyState(for key: ConnectionCapabilityKey) { guard let state = states[key], - state.leaseIDs.isEmpty, + state.leaseOwners.isEmpty, state.activationWaiters.isEmpty, state.releaseWaiters.isEmpty else { return diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index 9a138b22..185ad942 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -7,6 +7,35 @@ package enum ModelDomain: Hashable, Sendable { 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) + } + + var capabilityDependencies: [WebInspectorProxyEventDomain] { + switch self { + case .dom: + [.dom] + case .css: + [.dom, .css] + case .network: + [.network] + case .console: + [.console] + case .runtime: + [.runtime] + } + } } package struct ModelTarget: Equatable, Sendable { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 1b4bc19e..3e49a83f 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -46,10 +46,23 @@ private struct PhysicalTargetDisappearanceWaiters: Sendable { var release: [ReplyPromise] = [] } +private struct ConnectionModelFeedCapabilityLease: Sendable { + let owner: ConnectionCapabilityLeaseOwner + let key: ConnectionCapabilityKey +} + +private enum ConnectionModelFeedLifecycle: Sendable { + case acquiring + case active + case closing(ReplyPromise) +} + private struct ConnectionModelFeedRegistration: Sendable { let id: ConnectionModelFeedID let configuredDomains: Set let mailbox: ConnectionModelFeedMailbox + var lifecycle: ConnectionModelFeedLifecycle + var capabilityLeases: [ConnectionModelFeedCapabilityLease] var targetSnapshotThrough: UInt64? var resetGeneration: WebInspectorPage.Generation } @@ -315,7 +328,7 @@ package actor ConnectionCore { package func openModelFeed( configuredDomains: Set, capacity: Int - ) throws -> ConnectionModelFeed { + ) async throws -> ConnectionModelFeed { precondition(capacity > 0, "A bounded model feed must have a positive capacity.") guard isOpen else { throw terminalScopeError @@ -342,6 +355,8 @@ package actor ConnectionCore { id: id, configuredDomains: configuredDomains, mailbox: mailbox, + lifecycle: .acquiring, + capabilityLeases: [], targetSnapshotThrough: nil, resetGeneration: resetGeneration ) @@ -353,19 +368,198 @@ package actor ConnectionCore { !publishModelTargetSnapshot() { throw ConnectionModelFeedError.bufferOverflow(capacity: capacity) } - return feed + + do { + for domain in ModelDomain.ordered(configuredDomains) { + let leaseOwner = ConnectionCapabilityLeaseOwner.modelFeed(id, domain) + for capabilityDomain in domain.capabilityDependencies { + 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 + ) + } + } + try Task.checkCancellation() + guard isOpen else { + throw terminalScopeError + } + guard var 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.") + } + registration.lifecycle = .active + modelFeed = registration + 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 + } } package func closeModelFeed(_ id: ConnectionModelFeedID) async throws { - guard let registration = modelFeed, + guard var registration = modelFeed, registration.id == id else { return } - guard case .open = state else { - return + switch registration.lifecycle { + case .acquiring: + preconditionFailure("A model feed cannot close before openModelFeed returns.") + case .closing(let completion): + return try await completion.value() + case .active: + break + } + + let completion = ReplyPromise() + registration.lifecycle = .closing(completion) + modelFeed = registration + + 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) + await completion.fulfill(.failure(cleanupError)) + throw cleanupError + } + + guard isOpen else { + do { + try await waitUntilClosed() + await completion.fulfill(.success(())) + return + } catch { + await 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 - registration.mailbox.finish() + currentRegistration.mailbox.finish() + await 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 + } + + private func rollbackModelFeedAcquisition( + _ id: ConnectionModelFeedID + ) async -> (any Swift.Error)? { + guard let registration = modelFeed, + registration.id == id else { + return await modelFeedTerminalErrorIfNeeded() + } + 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 { + preconditionFailure("A model feed lost its registration before rollback completed.") + } + 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() + } } package func pageGeneration() throws -> WebInspectorPage.Generation { @@ -417,34 +611,19 @@ package actor ConnectionCore { ) resumeEventScopeRegistrationWaitersIfNeeded() - var capability = capabilities.states[key] - ?? ConnectionCapabilityRegistry.State(physical: .inactive(generation: generation)) - precondition(capability.leaseIDs.insert(scopeID).inserted, "Duplicate capability lease identifier.") - - let activation: ReplyPromise? - if case let .enabled(activeGeneration) = capability.physical, - activeGeneration == generation { - activation = nil - precondition( - capability.activatedLeaseIDs.insert(scopeID).inserted, - "A newly registered capability lease was already activated." - ) - } else { - let promise = ReplyPromise() - capability.activationWaiters[scopeID] = promise - activation = promise - } - capabilities.states[key] = capability - await reconcileCapability(for: key) + let leaseOwner = ConnectionCapabilityLeaseOwner.eventScope(scopeID) + let activation = beginCapabilityLease( + leaseOwner, + for: key, + generation: generation + ) do { - try await withTaskCancellationHandler { - try await activation?.value() - } onCancel: { - Task { [weak self] in - await self?.cancelEventScopeActivation(scopeID, key: key) - } - } + try await activateCapabilityLease( + leaseOwner, + for: key, + activation: activation + ) } catch is CancellationError { let cancellation = CancellationError() do { @@ -457,7 +636,9 @@ package actor ConnectionCore { } throw cancellation } catch { - await abandonEventScopeAfterFailedAcquisition(scopeID, key: key) + eventScopes.remove(scopeID)?.sink?.finish(nil) + resumeEventScopeRegistrationWaitersIfNeeded() + await abandonCapabilityLease(leaseOwner, for: key) throw error } @@ -472,51 +653,7 @@ package actor ConnectionCore { entry.sink?.finish(nil) let key = entry.capability - guard var capability = capabilities.states[key], - capability.leaseIDs.remove(id) != nil else { - return - } - capability.failedLeaseIDs.remove(id) - capability.activatedLeaseIDs.remove(id) - capability.activationWaiters.removeValue(forKey: id) - - 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[id] = promise - cleanup = promise - case let .enabling(generation, operationID, _): - let promise = ReplyPromise() - capability.releaseWaiters[id] = promise - capability.physical = .enabling( - generation: generation, - operationID: operationID, - mustDisableAfterEnable: true - ) - cleanup = promise - case .disabling: - let promise = ReplyPromise() - capability.releaseWaiters[id] = promise - cleanup = promise - } - capabilities.states[key] = capability - await reconcileCapability(for: key) - try await cleanup?.value() - capabilities.removeEmptyState(for: key) + try await releaseCapabilityLease(.eventScope(id), for: key) } package func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { @@ -711,6 +848,12 @@ package actor ConnectionCore { replyStore.pendingReplyPurposes } + func capabilityLeaseOwnersForTesting() -> [ + ConnectionCapabilityKey: Set + ] { + capabilities.states.mapValues(\.leaseOwners) + } + package func targetID(forExecutionContext key: RuntimeContext.Key) -> ProtocolTarget.ID? { runtimeContextRegistry.targetID(for: key) } @@ -883,36 +1026,134 @@ package actor ConnectionCore { ) } - private func cancelEventScopeActivation( - _ id: WebInspectorProxyEventScopeID, - key: ConnectionCapabilityKey + 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 { + try await withTaskCancellationHandler { + await reconcileCapability(for: key) + try Task.checkCancellation() + try await activation?.value() + } onCancel: { [weak self] in + Task { + await self?.cancelCapabilityActivation(leaseOwner, for: key) + } + } + } + + private func cancelCapabilityActivation( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey ) async { guard var capability = capabilities.states[key], - let waiter = capability.activationWaiters.removeValue(forKey: id) else { + let waiter = capability.activationWaiters.removeValue(forKey: leaseOwner) else { return } - capability.failedLeaseIDs.insert(id) + capability.failedLeaseOwners.insert(leaseOwner) capabilities.states[key] = capability - let cancellationAction = eventScopeActivationCancellationAction - eventScopeActivationCancellationAction = nil - await cancellationAction?() + if case .eventScope = leaseOwner { + let cancellationAction = eventScopeActivationCancellationAction + eventScopeActivationCancellationAction = nil + await cancellationAction?() + } await waiter.fulfill(.failure(CancellationError())) } - private func abandonEventScopeAfterFailedAcquisition( - _ id: WebInspectorProxyEventScopeID, - key: ConnectionCapabilityKey + 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) + capability.activationWaiters.removeValue(forKey: leaseOwner) + + 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?.value() + capabilities.removeEmptyState(for: key) + } + + private func abandonCapabilityLease( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey ) async { - eventScopes.remove(id)?.sink?.finish(nil) - resumeEventScopeRegistrationWaitersIfNeeded() guard var capability = capabilities.states[key] else { return } - capability.leaseIDs.remove(id) - capability.failedLeaseIDs.remove(id) - capability.activatedLeaseIDs.remove(id) - capability.activationWaiters.removeValue(forKey: id) - capability.releaseWaiters.removeValue(forKey: id) + 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) @@ -933,7 +1174,7 @@ package actor ConnectionCore { if key.domain == .dom { if capability.desiredCount > 0 { capability.physical = .enabled(generation: expectedGeneration) - capability.activatedLeaseIDs.formUnion(capability.activationWaiters.keys) + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) let waiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() capabilities.states[key] = capability @@ -1193,7 +1434,7 @@ package actor ConnectionCore { startCapabilityDisable(for: key, generation: generation) return } - capability.activatedLeaseIDs.formUnion(capability.activationWaiters.keys) + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) let waiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() let releaseWaiters = Array(capability.releaseWaiters.values) @@ -1209,7 +1450,7 @@ package actor ConnectionCore { let activeLeaseFailed = capability.hasActivatedDesiredLease let wireStateIsKnownInactive = Self.enableFailureProvesInactive(error) let failedIDs = Set(capability.activationWaiters.keys) - capability.failedLeaseIDs.formUnion(failedIDs) + capability.failedLeaseOwners.formUnion(failedIDs) let activationWaiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() capability.physical = .inactive(generation: generation) @@ -1272,7 +1513,7 @@ package actor ConnectionCore { // 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.activatedLeaseIDs.formUnion(capability.activationWaiters.keys) + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) let activationWaiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() let releaseWaiters = Array(capability.releaseWaiters.values) @@ -1293,7 +1534,7 @@ package actor ConnectionCore { // target lookup wins that race, the vanished target makes // cleanup complete without establishing reusable wire state. let failedIDs = Set(capability.activationWaiters.keys) - capability.failedLeaseIDs.formUnion(failedIDs) + capability.failedLeaseOwners.formUnion(failedIDs) capability.physical = .inactive(generation: generation) let activationWaiters = Array(capability.activationWaiters.values) capability.activationWaiters.removeAll() @@ -2230,7 +2471,7 @@ package actor ConnectionCore { guard var capability = capabilities.states[key] else { continue } - capability.failedLeaseIDs.formUnion(capability.activationWaiters.keys) + capability.failedLeaseOwners.formUnion(capability.activationWaiters.keys) waiters.activation.append(contentsOf: capability.activationWaiters.values) waiters.release.append(contentsOf: capability.releaseWaiters.values) capability.activationWaiters.removeAll() diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index b98b9676..906b25d2 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -139,13 +139,23 @@ func modelFeedUnavailableBindingUsesOneGenerationForResetSnapshotAndSynchronizat @Test func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWatermark() async throws { - let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + 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: [.network], capacity: 8) + let openTask = Task { + try await core.openModelFeed(configuredDomains: [.network], capacity: 8) + } + 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 var iterator = feed.records.makeAsyncIterator() _ = try await modelFeedRequireReset(iterator.next()) let initialSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) @@ -184,7 +194,16 @@ func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWaterma } #expect(id == Network.Request.ID("request-1")) - try await feed.close() + 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() } @@ -610,6 +629,620 @@ func replacementMainPageWaiterResumesAfterCapabilityOwnershipIsReconciled() asyn _ = 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 == [ + "CSS.enable", + "Network.enable", + "Console.enable", + "Runtime.enable", + ]) + #expect(try await modelFeedSentTargetMethods(backend) == enableMethods) + + 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", + ]) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + await core.close() +} + +@Test(arguments: [0, 1, 2, 3]) +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 openTask = Task { + try await core.openModelFeed( + configuredDomains: configuredDomains, + capacity: 32 + ) + } + + for index in 0...failureIndex { + let message = try await backend.waitForTargetMessage( + ordinal: 0, + after: 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[.. +) -> [String] { + var seenDomains: Set = [] + var methods: [String] = [] + for domain in ModelDomain.ordered(configuredDomains) { + for dependency in domain.capabilityDependencies where dependency != .dom { + guard seenDomains.insert(dependency).inserted else { + continue + } + methods.append("\(dependency.rawValue).enable") + } + } + return methods +} + +private func modelFeedOpenSuccessfully( + core: ConnectionCore, + backend: FakeTransportBackend, + configuredDomains: Set, + targetID: String +) async throws -> ConnectionModelFeed { + let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) + let targetMessageCount = await backend.sentTargetMessages().count + let openTask = Task { + try await core.openModelFeed( + configuredDomains: configuredDomains, + capacity: 32 + ) + } + for (offset, expectedMethod) in enableMethods.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) + } + 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().map { + try modelFeedMessageMethod($0.message) + } +} + +private func modelFeedAllCapabilityLeaseOwners( + _ core: ConnectionCore +) async -> Set { + await core.capabilityLeaseOwnersForTesting().values.reduce(into: []) { + $0.formUnion($1) + } +} + +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 struct ModelFeedEventRecord { let generation: WebInspectorPage.Generation let sequence: UInt64 From e5d289b3fbef09c79830da55026ae1f6bada2f56 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:16:07 +0900 Subject: [PATCH 29/81] feat(proxy): publish enable replay boundaries --- Docs/WebInspectorKitsArchitecture.md | 33 ++- .../Transport/ConnectionModelFeed.swift | 15 ++ .../Transport/TransportSession.swift | 85 +++++- .../ConnectionModelFeedTests.swift | 249 ++++++++++++++++++ 4 files changed, 365 insertions(+), 17 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 588d9de9..4d82a2b4 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -605,19 +605,31 @@ package enum ModelDomain: Hashable, Sendable { /* configured domains */ } The current transport slice implements the bounded exclusive feed, initial `reset`/`targetSnapshot`, future target lifecycle deltas, future events for -configured domains, and transactional capability leases. Acquisition follows -the deterministic domain order DOM, CSS, Network, Console, Runtime; CSS shares -the DOM prerequisite, while DOM's capability is local and sends no wire command. +configured domains, transactional capability leases, and enable-replay +boundaries for CSS, Network, Console, and Runtime. Acquisition follows the +deterministic domain order DOM, CSS, Network, Console, Runtime; CSS shares the +DOM prerequisite, while DOM's capability is local and sends no wire command. The feed is registered and its initial records are published before the first capability await, and `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`. -Replay-complete boundaries, DOM bootstrap, configured-domain synchronization, -and the complete ordered retarget readiness sequence described below remain -later slices; their record cases define the package contract but are not yet -emitted. This distinction prevents active capabilities from being mistaken for -model readiness behavior that the transport does not yet provide. + +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, non-empty configured-domain `synchronizationComplete`, and the +complete ordered retarget readiness sequence described below remain later +slices. Their record cases define the package contract but are not yet all +emitted. 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. @@ -728,8 +740,9 @@ When no physical page is temporarily committed, a command fails with `pageUnavailable`; it does not guess a stale target. During commit the core will eventually perform the following full ordered transition. The current slice establishes its synchronous reset, physical target snapshot, future-delta -prefix, and capability-owner reconciliation onto the new physical target; -replay/bootstrap watermarks and binding readiness remain later slices: +prefix, capability-owner reconciliation onto the new physical target, and the +wire capability replay watermarks described above; DOM bootstrap and binding +readiness remain later slices: 1. stop admission of new target-scoped commands and increment page generation; 2. publish `.reset(newGeneration)` to public scopes and the package model feed; diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index 185ad942..940b7811 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -36,6 +36,21 @@ package enum ModelDomain: Hashable, Sendable { [.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 { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 3e49a83f..2cfdc6c8 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -854,6 +854,12 @@ package actor ConnectionCore { 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) } @@ -2100,9 +2106,8 @@ package actor ConnectionCore { /// Synchronous phase boundary for successful reply-side effects. /// - /// The ordered model feed adds capability markers to this purpose switch - /// in the next change. It must remain in the actor's inbound processing - /// slot before `ReplyPromise.fulfill` resumes the waiting operation. + /// 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 @@ -2111,11 +2116,77 @@ package actor ConnectionCore { case .client: // Client replies have no internal publication side effect. break - case .capability: - // The next change publishes the ordered model-feed marker here. - break + case let .capability(key, generation, operationID): + publishModelReplayCompletionIfNeeded( + result, + pending: pending, + key: key, + generation: generation, + operationID: operationID + ) + } + } + + 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 + } + 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 { + guard enqueueModelFeedRecord( + .replayComplete( + generation: generation, + domain: domain, + through: result.receivedSequence + ) + ) else { + // enqueueModelFeedRecord synchronously claims terminal + // ownership for overflow or a terminated consumer. + return + } } - _ = result } private func updateRegistryFromRootEvent( diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 906b25d2..a04ebb8f 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -159,6 +159,10 @@ func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWaterma var iterator = feed.records.makeAsyncIterator() _ = try await modelFeedRequireReset(iterator.next()) let initialSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let initialReplay = try await modelFeedRequireReplayCompletion(iterator.next()) + #expect(initialReplay.generation == initialSnapshot.generation) + #expect(initialReplay.domain == .network) + #expect(initialReplay.through == initialSnapshot.through) let targetSequence = await core.receiveRootMessage(modelFeedTargetCreatedMessage( id: "frame-a", @@ -207,6 +211,58 @@ func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWaterma 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], capacity: 8) + } + 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()) + + #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) + + 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) @@ -655,6 +711,26 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as ]) #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 replayCompletions = try await [ + modelFeedRequireReplayCompletion(iterator.next()), + modelFeedRequireReplayCompletion(iterator.next()), + modelFeedRequireReplayCompletion(iterator.next()), + modelFeedRequireReplayCompletion(iterator.next()), + ] + #expect(replayCompletions.map(\.domain) == [ + .css, + .network, + .console, + .runtime, + ]) + #expect(replayCompletions.allSatisfy { + $0.generation == reset && $0.through == snapshot.through + }) + let owners = await core.capabilityLeaseOwnersForTesting() let domKey = ConnectionCapabilityKey( route: .currentPage, @@ -692,7 +768,120 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as "Network.disable", "CSS.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 replayMarkerOverflowClaimsTerminalStateAndCannotReturnAnOpenFeed() 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], + capacity: 2 + ) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + + await modelFeedRespond(to: enable, core: core) + #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) + do { + _ = try await openTask.value + Issue.record("A replay-marker overflow must not return an open model feed.") + } catch { + #expect(error is WebInspectorScopeError) + } + #expect(await backend.isDetached()) +} + +@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], + capacity: 2 + ) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond( + to: enable, + core: core, + errorMessage: "enable rejected" + ) + + await #expect(throws: WebInspectorProxyError.commandRejected( + method: "Network.enable", + message: "enable rejected" + )) { + try await openTask.value + } + #expect(await core.terminalCause == nil) + #expect(await backend.isDetached() == false) + + let replacement = try await core.openModelFeed( + configuredDomains: [], + capacity: 3 + ) + try await replacement.close() + await core.close() +} + +@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], + capacity: 2 + ) + } + 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: [], + capacity: 3 + ) + try await replacement.close() await core.close() } @@ -938,6 +1127,13 @@ func activeModelFeedLeaseReconcilesOntoReplacementTarget() async throws { configuredDomains: [.network], targetID: "page-old" ) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + let initialSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let initialReplay = try await modelFeedRequireReplayCompletion(iterator.next()) + #expect(initialReplay.generation == initialSnapshot.generation) + #expect(initialReplay.domain == .network) + #expect(initialReplay.through == initialSnapshot.through) _ = await core.receiveRootMessage( #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# @@ -963,8 +1159,27 @@ func activeModelFeedLeaseReconcilesOntoReplacementTarget() async throws { #expect(await modelFeedAllCapabilityLeaseOwners(core) == Set([ .modelFeed(feed.id, .network), ])) + let replacementEventSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "page-new", + message: #"{"method":"Network.requestWillBeSent","params":{"requestId":"replacement-replay","request":{"url":"https://example.test/replacement","method":"GET"},"timestamp":2,"type":"Document"}}"# + ) + ) await modelFeedRespond(to: replacementEnable, core: core) + let replacementReset = try await modelFeedRequireReset(iterator.next()) + let replacementSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let replacementEvent = try await modelFeedRequireEvent(iterator.next()) + let replacementReplay = try await modelFeedRequireReplayCompletion(iterator.next()) + #expect(replacementReset.rawValue == initialSnapshot.generation.rawValue + 1) + #expect(replacementSnapshot.generation == replacementReset) + #expect(replacementEvent.generation == replacementReset) + #expect(replacementEvent.sequence == replacementEventSequence) + #expect(replacementEvent.sequence > replacementSnapshot.through) + #expect(replacementReplay.generation == replacementReset) + #expect(replacementReplay.domain == .network) + #expect(replacementReplay.through == replacementEventSequence) + try await modelFeedCloseSuccessfully( feed, core: core, @@ -972,6 +1187,7 @@ func activeModelFeedLeaseReconcilesOntoReplacementTarget() async throws { targetID: "page-new", enableMethods: ["Network.enable"] ) + #expect(try await iterator.next() == nil) await core.close() } @@ -1212,6 +1428,19 @@ private func modelFeedAllCapabilityLeaseOwners( } } +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]) @@ -1260,6 +1489,12 @@ private struct ModelFeedSynchronizationRecord { let through: UInt64 } +private struct ModelFeedReplayCompletionRecord { + let generation: WebInspectorPage.Generation + let domain: ModelDomain + let through: UInt64 +} + private enum ModelFeedTestError: Error { case unexpectedRecord } @@ -1298,6 +1533,20 @@ private func modelFeedRequireSynchronization( 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 modelFeedRequireEvent( _ record: ConnectionModelFeedRecord? ) throws -> ModelFeedEventRecord { From 882f2abad347506adae123fae09f005807562609 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:16:16 +0900 Subject: [PATCH 30/81] refactor(data): move Runtime state to its owner --- .../WebInspectorDataKit/RuntimeContext.swift | 5 +- .../RuntimeStateStore.swift | 368 +++++++++++++++++ .../WebInspectorContext.swift | 311 +++----------- .../RuntimeStateStoreTests.swift | 380 ++++++++++++++++++ .../WebInspectorDataKitTests.swift | 175 ++++++++ 5 files changed, 976 insertions(+), 263 deletions(-) create mode 100644 Sources/WebInspectorDataKit/RuntimeStateStore.swift create mode 100644 Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift 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/RuntimeStateStore.swift b/Sources/WebInspectorDataKit/RuntimeStateStore.swift new file mode 100644 index 00000000..6a0c0e19 --- /dev/null +++ b/Sources/WebInspectorDataKit/RuntimeStateStore.swift @@ -0,0 +1,368 @@ +import WebInspectorProxyKit + +/// Owns Runtime execution-context identity, selection, remote-object identity, +/// and local object membership. +/// +/// `WebInspectorContext` 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 client + 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 + + package init() { + contextsByID = [:] + orderedContextIDs = [] + selectedContextID = nil + objectsByID = [:] + nextSyntheticObjectOrdinal = 0 + defaultExecutionGeneration = 0 + } + + 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?, + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + guard let context else { + selectedContextID = nil + return + } + guard contextsByID[context.id] === context else { + preconditionFailure("RuntimeContext is not registered in this WebInspectorContext.") + } + selectedContextID = context.id + } + + package func evaluationBinding( + for context: RuntimeContext?, + isolation: isolated (any Actor) = #isolation + ) throws -> EvaluationBinding { + _ = isolation + 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, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) throws -> RuntimeEvaluation { + _ = isolation + 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 WebInspectorContext." + ) + } + return RuntimeEvaluation( + object: register(result.object, owner: .client, modelContext: modelContext), + isException: result.wasThrown + ) + } + + package func objectBinding( + for object: RuntimeObject, + isolation: isolated (any Actor) = #isolation + ) throws -> ObjectBinding? { + _ = isolation + let object = try requireRegisteredObject(object) + return object.proxyID.map { ObjectBinding(remoteID: $0, object: object) } + } + + package func finishProperties( + _ descriptors: [Runtime.PropertyDescriptor], + binding: ObjectBinding, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) throws -> [RuntimeObject.Property] { + _ = isolation + try requireRegisteredObject(binding.object) + return descriptors.map { descriptor in + let remoteValue = descriptor.value + let childObject = remoteValue.flatMap { value in + value.id == nil ? nil : register(value, owner: .client, modelContext: modelContext) + } + return RuntimeObject.Property( + name: descriptor.name, + value: remoteValue.flatMap(Self.valueText), + object: childObject + ) + } + } + + package func finishCollectionEntries( + _ entries: [Runtime.CollectionEntry], + binding: ObjectBinding, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) throws -> [RuntimeObject.Entry] { + _ = isolation + try requireRegisteredObject(binding.object) + return entries.map { entry in + RuntimeObject.Entry( + key: entry.key.map { register($0, owner: .client, modelContext: modelContext) }, + value: register(entry.value, owner: .client, modelContext: modelContext) + ) + } + } + + package func registerConsoleParameter( + _ payload: Runtime.RemoteObject, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) -> RuntimeObject { + _ = isolation + return register(payload, owner: .console, modelContext: modelContext) + } + + package func removeConsoleOwnership( + from objects: [RuntimeObject], + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + 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( + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + 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?, + isolation: isolated (any Actor) = #isolation + ) { + _ = isolation + switch event { + case let .executionContextCreated(context): + applyExecutionContextCreated(context) + case let .executionContextDestroyed(id): + applyExecutionContextDestroyed(id) + case let .executionContextsCleared(eventTargetID): + if eventTargetID == .currentPage || eventTargetID == sourceTargetID { + reset(isolation: isolation) + } else { + clear(targetID: eventTargetID) + } + case .unknown: + break + } + } + + package func reset(isolation: isolated (any Actor) = #isolation) { + _ = isolation + contextsByID = [:] + orderedContextIDs = [] + selectedContextID = nil + objectsByID = [:] + advanceDefaultExecutionGeneration() + } + + 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, + modelContext: WebInspectorContext + ) -> 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, modelContext: modelContext) + 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 WebInspectorContext." + ) + } + return context + } + + @discardableResult + private func requireRegisteredObject(_ object: RuntimeObject) throws -> RuntimeObject { + guard objectsByID[object.id]?.object === object else { + throw WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + ) + } + return object + } + + 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/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 8f27cd81..37089705 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -56,11 +56,6 @@ public final class WebInspectorContext { } } - private enum RuntimeObjectOwner: Hashable { - case client - case console - } - private typealias LoadedDOMDocument = (node: DOM.Node, generation: Int) #if DEBUG @@ -102,6 +97,7 @@ public final class WebInspectorContext { private let domainEnablement: WebInspectorDomainEnablementRegistry private let owner: any Actor private let domState: DOMStateStore + private let runtimeState: RuntimeStateStore /// The current attachment state. public private(set) var state: State @@ -125,10 +121,14 @@ public final class WebInspectorContext { } /// Runtime execution contexts known to the current page. - public private(set) var executionContexts: [RuntimeContext] + public var executionContexts: [RuntimeContext] { + runtimeState.executionContexts + } /// The selected Runtime execution context. - public private(set) var selectedContext: RuntimeContext? + public var selectedContext: RuntimeContext? { + runtimeState.selectedContext + } private var currentPage: WebInspectorTarget? private var currentPageGeneration: Int @@ -155,12 +155,6 @@ public final class WebInspectorContext { private let statusRelay: WebInspectorAsyncStreamRelay private let networkRequests: NetworkRequestStore private let consoleMessages: ConsoleMessageStore - 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 consoleObjectGroupReleaseTasks: [WebInspectorTarget.ID: Task] /// Creates a context owned by the supplied actor. @@ -170,10 +164,9 @@ public final class WebInspectorContext { domainEnablement = container.domainEnablement owner = isolation domState = DOMStateStore() + runtimeState = RuntimeStateStore() state = .attaching teardownError = nil - executionContexts = [] - selectedContext = nil currentPage = nil currentPageGeneration = 0 startupTask = nil @@ -199,12 +192,6 @@ public final class WebInspectorContext { statusRelay = WebInspectorAsyncStreamRelay() networkRequests = NetworkRequestStore() consoleMessages = ConsoleMessageStore() - runtimeContextsByID = [:] - orderedRuntimeContextIDs = [] - runtimeObjectsByID = [:] - runtimeObjectIDsByProxyID = [:] - runtimeObjectOwnersByID = [:] - nextRuntimeObjectOrdinal = 0 consoleObjectGroupReleaseTasks = [:] WebInspectorDataKitLog.debug("context state=\(state.logDescription)") } @@ -707,14 +694,7 @@ public final class WebInspectorContext { /// 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 + runtimeState.select(context, isolation: isolation) } /// Evaluates JavaScript in the selected or supplied Runtime context. @@ -724,19 +704,17 @@ public final class WebInspectorContext { 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 - } + let binding = try runtimeState.evaluationBinding(for: context, isolation: isolation) 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 + let result = try await currentPage.runtime.evaluate(expression, in: binding.executionContextID) + return try runtimeState.finishEvaluation( + result, + binding: binding, + modelContext: self, + isolation: isolation ) } @@ -971,26 +949,20 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) async throws -> [RuntimeObject.Property] { requireOwner(isolation) - try registeredRuntimeObject(object) - guard let proxyID = object.proxyID else { + guard let binding = try runtimeState.objectBinding(for: object, isolation: isolation) 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 - ) - } + let descriptors = try await currentPage.runtime.properties(of: binding.remoteID) + return try runtimeState.finishProperties( + descriptors, + binding: binding, + modelContext: self, + isolation: isolation + ) } func collectionEntries( @@ -998,134 +970,20 @@ public final class WebInspectorContext { isolation: isolated (any Actor) = #isolation ) async throws -> [RuntimeObject.Entry] { requireOwner(isolation) - try registeredRuntimeObject(object) - guard let proxyID = object.proxyID else { + guard let binding = try runtimeState.objectBinding(for: object, isolation: isolation) 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 - } + let entries = try await currentPage.runtime.collectionEntries(of: binding.remoteID) + return try runtimeState.finishCollectionEntries( + entries, + binding: binding, + modelContext: self, + isolation: isolation + ) } /// Stops observing the inspected page and tears down context-owned state. @@ -1407,7 +1265,7 @@ public final class WebInspectorContext { private func resetReplayBackedModelsBeforeEnable( isolation: isolated (any Actor) ) { - clearExecutionContexts() + runtimeState.reset(isolation: isolation) consoleMessages.resetForReplay(modelContext: self, isolation: isolation) } @@ -1419,7 +1277,7 @@ public final class WebInspectorContext { private func resetCurrentPageLifecycleModels(isolation: isolated (any Actor)) { resetDOM(isolation: isolation) - clearExecutionContexts() + runtimeState.reset(isolation: isolation) clearConsoleMessages(isolation: isolation) } @@ -1947,7 +1805,7 @@ extension WebInspectorContext { } domState.advanceDocumentEpoch(isolation: isolation) resetDOM(isolation: isolation) - clearExecutionContexts() + runtimeState.reset(isolation: isolation) guard currentPageRetargetTask == nil, state != .attaching else { return @@ -2716,11 +2574,15 @@ extension WebInspectorContext { targetID: targetID, modelContext: self, registerRuntimeObject: { [self] payload in - registerRuntimeObject(payload, owner: .console) + runtimeState.registerConsoleParameter( + payload, + modelContext: self, + isolation: isolation + ) }, isolation: isolation ) - applyConsoleMessageEffects(effects) + applyConsoleMessageEffects(effects, isolation: isolation) switch effects.runtimeObjectGroupRelease { case .currentPage: releaseConsoleRuntimeObjectGroup(isolation: isolation) @@ -2736,17 +2598,21 @@ extension WebInspectorContext { modelContext: self, isolation: isolation ) - applyConsoleMessageEffects(effects) + applyConsoleMessageEffects(effects, isolation: isolation) } - private func applyConsoleMessageEffects(_ effects: ConsoleMessageStore.Effects) { + private func applyConsoleMessageEffects( + _ effects: ConsoleMessageStore.Effects, + isolation: isolated (any Actor) + ) { if effects.clearedAllMessages { - unregisterRuntimeObjects(owner: .console) + runtimeState.removeAllConsoleOwnership(isolation: isolation) return } - for object in effects.runtimeObjectsToUnregister { - unregisterRuntimeObject(object, owner: .console) - } + runtimeState.removeConsoleOwnership( + from: effects.runtimeObjectsToUnregister, + isolation: isolation + ) } private func releaseConsoleRuntimeObjectGroup( @@ -2793,79 +2659,6 @@ extension WebInspectorContext { 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 + runtimeState.apply(event, sourceTargetID: targetID, isolation: isolation) } } diff --git a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift new file mode 100644 index 00000000..80660cd6 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift @@ -0,0 +1,380 @@ +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"), + isolation: self + ) + return (store.executionContexts.count, store.selectedContext?.name) + } +} + +@Test +func runtimeStateStoreFollowsCustomActorWithoutRetainingIt() 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"), + isolation: MainActor.shared + ) + 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"), + isolation: MainActor.shared + ) + #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"), + isolation: MainActor.shared + ) + let second = try #require(store.executionContexts.last) + store.select(second, isolation: MainActor.shared) + #expect(store.selectedContext === second) + + store.apply( + .executionContextDestroyed(secondID), + sourceTargetID: WebInspectorTarget.ID("page"), + isolation: MainActor.shared + ) + #expect(store.executionContexts == [first]) + #expect(store.selectedContext === first) +} + +@MainActor +@Test +func runtimeStateStoreRemovesOwnershipOnlyForTheRegisteredObjectIdentity() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let remoteObject = Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("shared-id"), + kind: .object, + description: "registered" + ) + let registered = store.registerConsoleParameter( + remoteObject, + modelContext: context, + isolation: MainActor.shared + ) + let impostor = RuntimeObject( + id: registered.id, + remoteObject: remoteObject, + modelContext: context + ) + + store.removeConsoleOwnership(from: [impostor], isolation: MainActor.shared) + #expect(try store.objectBinding(for: registered, isolation: MainActor.shared)?.remoteID == remoteObject.id) + + store.removeConsoleOwnership(from: [registered], isolation: MainActor.shared) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) { + try store.objectBinding(for: registered, isolation: MainActor.shared) + } +} + +@MainActor +@Test +func runtimeStateStorePreservesClientOwnershipAfterConsoleOwnershipEnds() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let objectID = Runtime.RemoteObject.ID("client-and-console") + let consoleObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: objectID, kind: .object, description: "console"), + modelContext: context, + isolation: MainActor.shared + ) + let binding = try store.evaluationBinding(for: nil, isolation: MainActor.shared) + let evaluation = try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: objectID, kind: .object, description: "client") + ), + binding: binding, + modelContext: context, + isolation: MainActor.shared + ) + + #expect(evaluation.object === consoleObject) + #expect(consoleObject.description == "client") + + store.removeConsoleOwnership(from: [consoleObject], isolation: MainActor.shared) + #expect(try store.objectBinding(for: consoleObject, isolation: MainActor.shared)?.remoteID == objectID) +} + +@MainActor +@Test +func runtimeStateStoreTargetClearRemovesObjectsWithoutExecutionContexts() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let pageTargetID = WebInspectorTarget.ID("page") + let frameTargetID = WebInspectorTarget.ID("frame") + let pageObject = store.registerConsoleParameter( + Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("page-object", scopedToTargetRawValue: pageTargetID.rawValue), + kind: .object + ), + modelContext: context, + isolation: MainActor.shared + ) + let frameObject = store.registerConsoleParameter( + Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("frame-object", scopedToTargetRawValue: frameTargetID.rawValue), + kind: .object + ), + modelContext: context, + isolation: MainActor.shared + ) + #expect(store.executionContexts.isEmpty) + + store.apply( + .executionContextsCleared(target: frameTargetID), + sourceTargetID: pageTargetID, + isolation: MainActor.shared + ) + + #expect(try store.objectBinding(for: pageObject, isolation: MainActor.shared) != nil) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) { + try store.objectBinding(for: frameObject, isolation: MainActor.shared) + } +} + +@MainActor +@Test +func runtimeStateStoreTargetClearPreservesOtherTargetContexts() 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, + isolation: MainActor.shared + ) + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: frameContextID, + name: "Frame", + kind: .normal + )), + sourceTargetID: frameTargetID, + isolation: MainActor.shared + ) + let pageContext = try #require(store.executionContexts.first) + let frameContext = try #require(store.executionContexts.last) + store.select(frameContext, isolation: MainActor.shared) + + store.apply( + .executionContextsCleared(target: frameTargetID), + sourceTargetID: pageTargetID, + isolation: MainActor.shared + ) + + #expect(store.executionContexts.count == 1) + #expect(store.executionContexts.first === pageContext) + #expect(store.selectedContext === pageContext) +} + +@MainActor +@Test +func runtimeStateStoreOldPointerCannotRemoveSameRemoteIDReplacement() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let remoteID = Runtime.RemoteObject.ID("reused-remote-id") + let oldObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "old"), + modelContext: context, + isolation: MainActor.shared + ) + + store.reset(isolation: MainActor.shared) + + let replacement = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "replacement"), + modelContext: context, + isolation: MainActor.shared + ) + #expect(replacement !== oldObject) + store.removeConsoleOwnership(from: [oldObject], isolation: MainActor.shared) + + #expect(try store.objectBinding(for: replacement, isolation: MainActor.shared)?.remoteID == remoteID) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) { + try store.objectBinding(for: oldObject, isolation: MainActor.shared) + } +} + +@MainActor +@Test +func runtimeStateStoreNeverReusesSyntheticIdentityAfterReset() { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let first = store.registerConsoleParameter( + Runtime.RemoteObject(id: nil, kind: .number, value: .number(1)), + modelContext: context, + isolation: MainActor.shared + ) + + store.reset(isolation: MainActor.shared) + + let second = store.registerConsoleParameter( + Runtime.RemoteObject(id: nil, kind: .number, value: .number(2)), + modelContext: context, + isolation: MainActor.shared + ) + #expect(first.id != second.id) + #expect(first !== second) +} + +@MainActor +@Test +func runtimeStateStoreRejectsDefaultEvaluationReplyAfterFullReset() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = RuntimeStateStore() + let binding = try store.evaluationBinding(for: nil, isolation: MainActor.shared) + + store.reset(isolation: MainActor.shared) + + #expect(throws: WebInspectorProxyError.disconnected( + "Runtime evaluation target is no longer current in this WebInspectorContext." + )) { + try store.finishEvaluation( + Runtime.EvaluationResult(object: Runtime.RemoteObject(id: nil, kind: .undefined)), + binding: binding, + modelContext: context, + isolation: MainActor.shared + ) + } +} + +@MainActor +@Test +func runtimeStateStoreDoesNotRetainRuntimeObjectModelContext() { + let store = RuntimeStateStore() + weak var releasedContext: WebInspectorContext? + let object: RuntimeObject + + do { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + releasedContext = context + object = store.registerConsoleParameter( + Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("weak-model-context"), + kind: .object + ), + modelContext: context, + isolation: MainActor.shared + ) + #expect(object.modelContext === context) + } + + #expect(releasedContext == nil) + #expect(object.modelContext == nil) +} + +@MainActor +@Test +func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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"), + isolation: MainActor.shared + ) + let original = try #require(store.executionContexts.first) + let binding = try store.evaluationBinding(for: original, isolation: MainActor.shared) + + store.apply( + .executionContextDestroyed(contextID), + sourceTargetID: WebInspectorTarget.ID("page"), + isolation: MainActor.shared + ) + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "After", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isolation: MainActor.shared + ) + + #expect(store.executionContexts.first !== original) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeContext is not registered in this WebInspectorContext." + )) { + try store.finishEvaluation( + Runtime.EvaluationResult(object: Runtime.RemoteObject(id: nil, kind: .undefined)), + binding: binding, + modelContext: context, + isolation: MainActor.shared + ) + } +} diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 79a7e2bb..5684bd71 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -7138,6 +7138,181 @@ func staleRuntimeObjectThrowsWithoutFailingContext() async throws { #expect(context.state == .attached) } +@MainActor +@Test +func runtimeEvaluationRejectsReplyAfterRuntimeClearWithoutKnownContexts() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let (target, context) = try await startContext(runtime: runtime) + #expect(context.executionContexts.isEmpty) + let gate = WebInspectorTestGate() + await runtime.backend.hold(domain: "Runtime", method: "evaluate", gate: gate) + await runtime.backend.enqueue( + Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-evaluation-reply"), + kind: .object + ) + ), + for: "Runtime", + method: "evaluate" + ) + + let evaluationTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await context.evaluate("window") + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected evaluation error: \(error)") + return nil + } + } + _ = await runtime.backend.waitForRecordedCommands(domain: "Runtime", method: "evaluate", count: 1) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + await gate.open() + + #expect(await evaluationTask.value == .disconnected( + "Runtime evaluation target is no longer current in this WebInspectorContext." + )) + #expect(context.state == .attached) +} + +@MainActor +@Test +func runtimePropertiesRejectReplyAfterObjectClear() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("properties-race") + await runtime.backend.emit( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Properties race", + kind: .normal + )), + target: target + ) + try await waitUntil { context.executionContexts.count == 1 } + await runtime.backend.enqueue( + Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("properties-root"), + kind: .object + ) + ), + for: "Runtime", + method: "evaluate" + ) + let root = try await context.evaluate("window").object + let gate = WebInspectorTestGate() + await runtime.backend.hold(domain: "Runtime", method: "getProperties", gate: gate) + await runtime.backend.enqueue( + [ + Runtime.PropertyDescriptor( + name: "child", + value: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-property-child"), + kind: .object + ) + ) + ], + for: "Runtime", + method: "getProperties" + ) + + let propertiesTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await root.properties() + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected Runtime.getProperties error: \(error)") + return nil + } + } + _ = await runtime.backend.waitForRecordedCommands(domain: "Runtime", method: "getProperties", count: 1) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + await gate.open() + + #expect(await propertiesTask.value == .disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) + #expect(context.state == .attached) +} + +@MainActor +@Test +func runtimeCollectionEntriesRejectReplyAfterObjectClear() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("entries-race") + await runtime.backend.emit( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Entries race", + kind: .normal + )), + target: target + ) + try await waitUntil { context.executionContexts.count == 1 } + await runtime.backend.enqueue( + Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("entries-root"), + kind: .object + ) + ), + for: "Runtime", + method: "evaluate" + ) + let root = try await context.evaluate("new Map()").object + let gate = WebInspectorTestGate() + await runtime.backend.hold(domain: "Runtime", method: "getCollectionEntries", gate: gate) + await runtime.backend.enqueue( + [ + Runtime.CollectionEntry( + value: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-entry-value"), + kind: .object + ) + ) + ], + for: "Runtime", + method: "getCollectionEntries" + ) + + let entriesTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await root.collectionEntries() + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected Runtime.getCollectionEntries error: \(error)") + return nil + } + } + _ = await runtime.backend.waitForRecordedCommands( + domain: "Runtime", + method: "getCollectionEntries", + count: 1 + ) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + await gate.open() + + #expect(await entriesTask.value == .disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) + #expect(context.state == .attached) +} + @MainActor @Test func runtimeEventsPopulateContextsAndFallbackSelection() async throws { From bfa3e47d4c951e6375f97e255a2713deb1b9b492 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:17:07 +0900 Subject: [PATCH 31/81] docs(architecture): keep CSS state with DOM ownership --- Docs/WebInspectorKitsArchitecture.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 4d82a2b4..42dfeae6 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -378,12 +378,11 @@ promise. | 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 | package-internal `DOMStateStore` | DOM events and DOM commands | +| 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 and query membership | 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 | -| CSS state and mutation | package-internal `CSSStateStore` | CSS events and awaited CSS commands | | 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 | @@ -1111,8 +1110,14 @@ write sets out of `WebInspectorContext`: collection epochs, weak fetched-results registrations, and compact Sendable records passed to their query indexes. - `RuntimeStateStore` owns execution-context identity and object-group - membership; `CSSStateStore` owns node-style resources, stale marking, and - awaited mutation serialization. + 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 @@ -1852,7 +1857,7 @@ the design gate and implementation: 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 CSS state to domain owners` +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` From aa58f5bfebc17f6948ed37b1644eb786906c7d8d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:43:30 +0900 Subject: [PATCH 32/81] refactor(data)!: remove domain controller forwarders --- Docs/WebInspectorKitsArchitecture.md | 3 +- Sources/WebInspectorDataKit/DOMMutation.swift | 26 ++ Sources/WebInspectorDataKit/Fetching.swift | 2 +- .../WebInspectorContainer.swift | 2 +- .../WebInspectorDataKit.md | 24 +- .../WebInspectorDomainControllers.swift | 371 ------------------ .../WebInspectorMutationOptions.swift | 39 ++ .../DOMNavigationItems.swift | 9 +- .../Element/DOMElementViewController.swift | 12 +- .../Tree/DOMTreeTextView.swift | 4 +- .../Tree/DOMTreeViewController.swift | 13 +- .../NetworkPanelModel.swift | 4 +- .../WebInspectorDataKitTests.swift | 107 +++-- 13 files changed, 184 insertions(+), 432 deletions(-) create mode 100644 Sources/WebInspectorDataKit/DOMMutation.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift create mode 100644 Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 42dfeae6..2552f4d8 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1606,7 +1606,8 @@ The migration removes rather than deprecates the following surfaces: caller-confined `WebInspectorModelContext` and one weak delivery bridge. - Stateless computed `DOMModelController`, `NetworkModelController`, `ConsoleModelController`, `RuntimeModelController`, `CSSModelController`, - `PageModelController`, and forwarding `WebInspectorEditHistory` wrappers. + `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. diff --git a/Sources/WebInspectorDataKit/DOMMutation.swift b/Sources/WebInspectorDataKit/DOMMutation.swift new file mode 100644 index 00000000..4ff7a990 --- /dev/null +++ b/Sources/WebInspectorDataKit/DOMMutation.swift @@ -0,0 +1,26 @@ +/// 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 + } +} diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 279a8119..5f690304 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -19,7 +19,7 @@ import Observation /// fetchLimit: 100 /// ) /// -/// let results = context.network.fetchedResults(for: descriptor) +/// let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) /// ``` public struct WebInspectorFetchDescriptor: Sendable { enum Kind: Hashable, Sendable { diff --git a/Sources/WebInspectorDataKit/WebInspectorContainer.swift b/Sources/WebInspectorDataKit/WebInspectorContainer.swift index e1fbee09..e959213c 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContainer.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContainer.swift @@ -14,7 +14,7 @@ import WebInspectorProxyKit /// let container = try await WebInspectorContainer(attachingTo: webView) /// let context = container.mainContext /// -/// let tree = context.dom.treeController() +/// let tree = try await context.treeController() /// render(tree.snapshot) /// /// await container.close() diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md index da686892..ab0e6d8c 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md +++ b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md @@ -25,7 +25,7 @@ final class InspectorModel { func attach(to webView: WKWebView) async throws { let container = try await WebInspectorContainer(attachingTo: webView) let context = container.mainContext - let tree = context.dom.treeController() + let tree = try await context.treeController() treeTask = Task { @MainActor in for await update in tree.updates { @@ -50,13 +50,13 @@ 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. -Use the domain controllers on ``WebInspectorContext`` for high-level operations: +Use ``WebInspectorContext`` directly for high-level operations: ```swift -try await context.dom.setInspectMode(enabled: true) -try await context.page.reload() +try await context.setElementPickerEnabled(true) +try await context.reloadPage() -let result = try await context.runtime.evaluate("document.title") +let result = try await context.evaluate("document.title") print(result.object.description ?? "") ``` @@ -96,12 +96,10 @@ access with no model layer. - ``CSSStyleSection`` - ``CSSStyleProperty`` -### Domain Operations +### Mutations -- ``DOMModelController`` -- ``NetworkModelController`` -- ``ConsoleModelController`` -- ``RuntimeModelController`` -- ``CSSModelController`` -- ``PageModelController`` -- ``WebInspectorEditHistory`` +- ``WebInspectorMutationOptions`` +- ``WebInspectorUndoPolicy`` +- ``WebInspectorStaleModelPolicy`` +- ``DOMRevealPolicy`` +- ``DOMMutationResult`` diff --git a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift b/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift deleted file mode 100644 index 5c998fbc..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift +++ /dev/null @@ -1,371 +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) - } - - /// 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) - } - -} - -/// 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/WebInspectorMutationOptions.swift b/Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift new file mode 100644 index 00000000..a1784cf1 --- /dev/null +++ b/Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift @@ -0,0 +1,39 @@ +/// 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 +} diff --git a/Sources/WebInspectorUIDOM/DOMNavigationItems.swift b/Sources/WebInspectorUIDOM/DOMNavigationItems.swift index b193691e..22f5b575 100644 --- a/Sources/WebInspectorUIDOM/DOMNavigationItems.swift +++ b/Sources/WebInspectorUIDOM/DOMNavigationItems.swift @@ -209,7 +209,7 @@ package final class DOMNavigationItems: NSObject { return } do { - try await context.page.reload() + try await context.reloadPage() } catch { WebInspectorUIDOMLog.debug("DOM reload failed: \(String(describing: error))") } @@ -240,7 +240,10 @@ package final class DOMNavigationItems: NSObject { return } do { - let result = try await context.dom.remove([selectedNode.id]) + let result = try await context.removeDOMNodes( + [selectedNode.id], + options: .automatic + ) let undoCommands = try context.domUndoRedoCommands() DOMDeletionUndoRegistration.registerDeleteUndo( on: undoManager, @@ -267,7 +270,7 @@ package final class DOMNavigationItems: NSObject { return } do { - try await context.dom.setInspectMode(enabled: !context.isElementPickerEnabled) + try await context.setElementPickerEnabled(!context.isElementPickerEnabled) } catch { WebInspectorUIDOMLog.debug("DOM picker toggle failed: \(String(describing: error))") } diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index 0a3459d0..60b3bd3b 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -45,7 +45,7 @@ package final class DOMElementViewController: UICollectionViewController { #if DEBUG resolveStyleRenderWaitersForTesting(result: false) #endif - context.css.setStyleHydrationActive(false) + context.setStyleHydrationActive(false) statusTask?.cancel() selectedStylesObservation?.cancel() } @@ -67,11 +67,11 @@ package final class DOMElementViewController: UICollectionViewController { override package func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) - context.css.setStyleHydrationActive(true) + context.setStyleHydrationActive(true) } override package func viewDidDisappear(_ animated: Bool) { - context.css.setStyleHydrationActive(false) + context.setStyleHydrationActive(false) super.viewDidDisappear(animated) } @@ -348,7 +348,11 @@ package final class DOMElementViewController: UICollectionViewController { private func toggleAction() -> DOMElementStylePropertyView.ToggleAction? { return { [weak context] propertyID, enabled in - context?.css.requestSetProperty(propertyID, enabled: enabled) ?? false + context?.requestSetCSSProperty( + propertyID, + enabled: enabled, + options: .automatic + ) ?? false } } diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift index 5f08a18a..624e4388 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift @@ -195,7 +195,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 +1079,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 diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift index c8f2d5d3..db78c9ae 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift @@ -19,7 +19,7 @@ package final class DOMTreeViewController: UIViewController { return false } do { - try await context.dom.requestChildren(of: nodeID) + try await context.requestChildren(for: nodeID) return true } catch { WebInspectorUIDOMLog.debug("DOM tree request children failed nodeID=\(String(describing: nodeID)): \(String(describing: error))") @@ -30,16 +30,16 @@ package final class DOMTreeViewController: UIViewController { guard let context else { return } - try await context.dom.highlight(nodeID) + try await context.highlightNode(for: nodeID) }, restoreHighlightAction: { [weak context] in guard let context else { return } if let selectedNode = context.selectedNode { - try await context.dom.highlight(selectedNode.id) + try await context.highlightNode(for: selectedNode.id) } else { - try await context.dom.hideHighlight() + try await context.hideHighlight() } }, copyNodeTextAction: { [weak context] nodeID, kind in @@ -100,7 +100,10 @@ package final class DOMTreeViewController: UIViewController { let deletedNodeCount: Int let undoCommands: WebInspectorContext.DOMUndoRedoCommands do { - let result = try await context.dom.remove(nodeIDs) + let result = try await context.removeDOMNodes( + nodeIDs, + options: .automatic + ) deletedNodeCount = result.acceptedNodeIDs.count undoCommands = try context.domUndoRedoCommands() } catch let error as WebInspectorContext.DOMDeletionPartialFailure { diff --git a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift index d66d8375..f5f73515 100644 --- a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift +++ b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift @@ -46,7 +46,7 @@ package final class NetworkPanelModel { package init(context: WebInspectorContext) { self.context = context - self.requests = context.network.fetchedResults(for: Self.makeNetworkFetchDescriptor(searchText: "", filters: [])) + self.requests = context.fetchedResults(for: Self.makeNetworkFetchDescriptor(searchText: "", filters: [])) self.collectionState = context.networkRequestsCollectionState self.responseBodyFetchCoordinator = NetworkResponseBodyFetchCoordinator() } @@ -116,7 +116,7 @@ package final class NetworkPanelModel { package func clearRequests() { selectedRequestID = nil - context.network.clearRequests() + context.clearNetworkRequests() } package func fetchResponseBodyIfNeeded(for request: NetworkRequest) { diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 5684bd71..35fec73e 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -255,19 +255,19 @@ func domCommandsDispatchThroughDataKitContext() async throws { #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) + try await context.highlightNode(for: child.id) await runtime.backend.enqueue((), for: "DOM", method: "hideHighlight") - try await context.dom.hideHighlight() + try await context.hideHighlight() await runtime.backend.enqueue((), for: "DOM", method: "undo") - try await context.editHistory.undo() + try await context.undoDOMChange() await runtime.backend.enqueue((), for: "DOM", method: "redo") - try await context.editHistory.redo() + try await context.redoDOMChange() await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.dom.setInspectMode(enabled: true) + try await context.setElementPickerEnabled(true) #expect(context.isElementPickerEnabled) await enqueueCSSStyleReplies(on: runtime.backend) @@ -279,23 +279,35 @@ func domCommandsDispatchThroughDataKitContext() async throws { 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) + try await context.setDOMAttribute( + "class", + value: "updated", + on: parent.id, + options: .automatic + ) await runtime.backend.enqueue((), for: "DOM", method: "setOuterHTML") await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.dom.setOuterHTML("", of: child.id) + try await context.setDOMOuterHTML( + "", + of: child.id, + options: .automatic + ) 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]) + let deletion = try await context.removeDOMNodes( + [parent.id, child.id], + options: .automatic + ) #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) + try await context.reloadPage(ignoringCache: true) let commands = await runtime.backend.recordedCommands() let outerHTML = try #require(commands.first { $0.domain == "DOM" && $0.method == "getOuterHTML" }) @@ -352,17 +364,25 @@ func domMutationsAndUndoRedoUseOwningFrameTarget() async throws { 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) + try await context.setDOMAttribute( + "data-edited", + value: "page", + on: document.id, + options: .automatic + ) await runtime.backend.enqueue((), for: "DOM", method: "removeNode") await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - _ = try await context.dom.remove([DOMNode.ID(scopedNodeID)]) + _ = try await context.removeDOMNodes( + [DOMNode.ID(scopedNodeID)], + options: .automatic + ) await runtime.backend.enqueue((), for: "DOM", method: "undo") - try await context.editHistory.undo() + try await context.undoDOMChange() await runtime.backend.enqueue((), for: "DOM", method: "redo") - try await context.editHistory.redo() + try await context.redoDOMChange() let domCommands = await runtime.backend.recordedCommands() .filter { $0.domain == "DOM" && ["setAttributeValue", "removeNode", "markUndoableState", "undo", "redo"].contains($0.method) } @@ -412,10 +432,10 @@ func domDeleteRejectsCrossTargetSelectionBeforeRemovingNodes() async throws { method: "removeNode", message: "Deleting nodes from multiple DOM targets in one mutation is not supported." )) { - _ = try await context.dom.remove([ + _ = try await context.removeDOMNodes([ DOMNode.ID(pageNodeID), DOMNode.ID(scopedFrameNodeID), - ]) + ], options: .automatic) } let removeCommands = await runtime.backend.recordedCommands() @@ -1537,7 +1557,7 @@ func transportBackedFrameRuntimeAndConsoleEventsKeepTargetScope() async throws { var capturedEvaluation: RuntimeEvaluation? let evaluationTask = Task { @MainActor in - capturedEvaluation = try await context.runtime.evaluate("window", in: frameContext) + capturedEvaluation = try await context.evaluate("window", in: frameContext) } let evaluate = try await waitForTransportTargetMessage( backend, @@ -1616,7 +1636,11 @@ func transportBackedStyleSheetTextEditRoutesToFrameTargetAndMarksUndo() async th ) let editTask = Task { @MainActor in - try await context.css.setStyleSheetText("body { color: red; }", for: styleSheetID) + try await context.setCSSStyleSheetText( + "body { color: red; }", + for: styleSheetID, + options: .automatic + ) } let setStyleSheetText = try await waitForTransportTargetMessage( backend, @@ -2386,7 +2410,12 @@ func documentUpdatedReloadsRootDocument() async throws { 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) + try await context.setDOMAttribute( + "data-before-reset", + value: "1", + on: document.id, + options: .automatic + ) let undoCommands = try context.domUndoRedoCommands() let controller = try await context.treeController() let recorder = DOMTreeUpdateRecorder(stream: controller.updates) @@ -2432,7 +2461,7 @@ func documentUpdatedReloadsRootDocument() async throws { } 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() + try await context.undoDOMChange() } let commands = await runtime.backend.recordedCommands() @@ -2999,7 +3028,7 @@ func domTreeControllerPublishesSelectionDeltasWithoutOwningExpansion() async thr #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 context.selectNode(child.id, reveal: .selectOnly) try await recorder.waitForUpdateCount(2) try await revealRecorder.waitForRequestCount(1) @@ -3026,7 +3055,7 @@ func domTreeControllerPublishesSelectionDeltasWithoutOwningExpansion() async thr #expect(selectionCleared == .selectionChanged(nodeID: nil)) #expect(controller.snapshot.selectedNodeID == nil) - try context.dom.select(document.id, reveal: .none) + try context.selectNode(document.id, reveal: .none) try await recorder.waitForUpdateCount(4) #expect(revealRecorder.requests.count == 1) } @@ -4936,7 +4965,7 @@ func styleSheetChangedWhileHydrationActiveTriggersImmediateRefetch() async throw ) let element = try await waitForChild(in: context) - context.css.setStyleHydrationActive(true) + context.setStyleHydrationActive(true) await enqueueCSSStyleReplies(on: runtime.backend) context.select(element) let styles = try #require(element.elementStyles) @@ -4978,7 +5007,7 @@ func styleSheetChangedWhileHydrationInactiveDefersRefetchUntilActivation() async #expect(await matchedStylesCommandCount(on: runtime.backend) == 1) await enqueueCSSStyleReplies(on: runtime.backend) - context.css.setStyleHydrationActive(true) + context.setStyleHydrationActive(true) try await waitUntil { styles.phase == .loaded } #expect(await matchedStylesCommandCount(on: runtime.backend) == 2) @@ -5000,7 +5029,7 @@ func requestSetCSSPropertyTogglesDeclarationAndRefreshesStyles() async throws { ) let element = try await waitForChild(in: context) - context.css.setStyleHydrationActive(true) + context.setStyleHydrationActive(true) await enqueueCSSStyleReplies(on: runtime.backend) context.select(element) let styles = try #require(element.elementStyles) @@ -5042,7 +5071,11 @@ func requestSetCSSPropertyTogglesDeclarationAndRefreshesStyles() async throws { 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)) + #expect(context.requestSetCSSProperty( + propertyID, + enabled: false, + options: .automatic + )) try await waitUntil { await matchedStylesCommandCount(on: runtime.backend) == 2 @@ -5103,7 +5136,11 @@ func setCSSDeclarationTextRewritesStyleTextAndMarksUndoableState() async throws 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) + try await context.setCSSDeclarationText( + "display: flex;", + for: propertyID, + options: .automatic + ) let commands = await runtime.backend.recordedCommands() let setStyleText = try #require(commands.last { $0 == RecordedCommand(domain: "CSS", method: "setStyleText") }) @@ -5142,7 +5179,11 @@ func cssRuleSelectorEditsMarkUndoableStateOnOwningTarget() async throws { ) await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.css.setRuleSelector(".updated", for: ruleID) + try await context.setCSSRuleSelector( + ".updated", + for: ruleID, + options: .automatic + ) let commands = await runtime.backend.recordedCommands() let setRuleSelector = try #require(commands.first { $0.domain == "CSS" && $0.method == "setRuleSelector" }) @@ -5226,14 +5267,22 @@ func requestSetCSSPropertyRefusesStaleAndNonEditableProperties() async throws { 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) + #expect(context.requestSetCSSProperty( + userAgentPropertyID, + enabled: false, + options: .automatic + ) == 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) + #expect(context.requestSetCSSProperty( + editablePropertyID, + enabled: false, + options: .automatic + ) == false) let commands = await runtime.backend.recordedCommands() #expect(commands.contains(RecordedCommand(domain: "CSS", method: "setStyleText")) == false) From 9c888572954d4271e62a5e52de357f4d683815b0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:51:29 +0900 Subject: [PATCH 33/81] fix(proxy): align Inspector selection with page agent --- Docs/WebInspectorKitsArchitecture.md | 24 +++- Sources/WebInspectorProxyKit/DOM.swift | 5 +- Sources/WebInspectorProxyKit/Inspector.swift | 12 +- .../LiveProxyEventDecoder.swift | 8 +- .../LiveWebInspectorProxyBackend.swift | 11 +- .../Transport/ConnectionEventProjection.swift | 23 +--- .../WebInspectorProxy.swift | 86 +++--------- .../WebInspectorProxyBackend.swift | 3 - .../WebInspectorDataKitTests.swift | 125 ------------------ .../WebInspectorProxyKitBackendTests.swift | 6 +- ...torProxyTransportCommandBackendTests.swift | 60 +++------ 11 files changed, 77 insertions(+), 286 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 2552f4d8..93552a64 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -134,7 +134,10 @@ External source evidence was read at fixed local revisions: 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`. + 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`. - 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. @@ -712,6 +715,25 @@ Element picker -> DOM + Inspector + inspect-mode lease 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 { diff --git a/Sources/WebInspectorProxyKit/DOM.swift b/Sources/WebInspectorProxyKit/DOM.swift index 280003c5..e8a85b37 100644 --- a/Sources/WebInspectorProxyKit/DOM.swift +++ b/Sources/WebInspectorProxyKit/DOM.swift @@ -65,7 +65,10 @@ public struct DOM: Sendable, WebInspectorEventDomainHandle { ) } - /// Resolves a runtime object into a DOM node identity. + /// 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", diff --git a/Sources/WebInspectorProxyKit/Inspector.swift b/Sources/WebInspectorProxyKit/Inspector.swift index 06a3a660..7076c76b 100644 --- a/Sources/WebInspectorProxyKit/Inspector.swift +++ b/Sources/WebInspectorProxyKit/Inspector.swift @@ -42,18 +42,8 @@ package struct Inspector: Sendable, WebInspectorDomainHandle { 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 0ec6a851..bea4aeb6 100644 --- a/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift +++ b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift @@ -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)) } diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index a0fa9b0e..e8e9f6be 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -19,7 +19,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, @@ -138,13 +138,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) diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift index 93740e8e..c5419f56 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift @@ -32,8 +32,8 @@ package enum ConnectionEventProjection { 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. + // 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 { @@ -41,7 +41,10 @@ package enum ConnectionEventProjection { } return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) case .inspector: - return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + // 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 @@ -107,10 +110,6 @@ package enum ConnectionEventProjection { let targetID = event.targetID else { return scopedProxyEvent } - if event.domain == .inspector, - targetID == snapshot.currentMainPageTargetID { - return Self.mainPageInspectorEvent(scopedProxyEvent) - } guard let currentMainPageTargetID = snapshot.currentMainPageTargetID, targetID != currentMainPageTargetID, let record = snapshot.targetsByID[targetID], @@ -169,16 +168,6 @@ package enum ConnectionEventProjection { return agentTargetID.rawValue } - private nonisolated static 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 static func scopedDOMEvent( _ event: DOM.Event, targetRawValue: String diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index 7c8bdbcb..41941cb9 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -8,16 +8,13 @@ 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 } } @@ -297,7 +294,6 @@ public actor WebInspectorProxy { let command = WebInspectorProxyCommand( targetID: commandTarget.targetID, route: commandTarget.route, - resultTargetScopeRawValue: commandTarget.resultTargetScopeRawValue, domain: domain, method: method, payload: payload @@ -330,7 +326,6 @@ public actor WebInspectorProxy { } await self.emitDOMInspectEvent( for: value, - targetID: targetID, route: route, continuation: continuation ) @@ -474,11 +469,10 @@ public actor WebInspectorProxy { 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 { + guard case let .inspect(object, _) = event else { return } guard object.subtype?.rawValue == "node", let objectID = object.id else { @@ -487,60 +481,30 @@ public actor WebInspectorProxy { ) 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)" + "Inspector.inspect resolving route=\(Self.logDescription(route), privacy: .public) objectID=\(objectID.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. + // Inspector.inspect is a page-target event. WebInspectorUI resolves it + // through the main page DOM agent, whose node namespace is unscoped. do { let nodeID: DOM.Node.ID = try await dispatchCommand( - targetID: targets.commandTargetID, - route: targets.commandRoute, + targetID: .currentPage, + route: .currentPage, 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)" + "Inspector.inspect resolved objectID=\(objectID.rawValue, privacy: .public) nodeID=\(nodeID.rawValue, privacy: .public)" ) - continuation.yield(.inspect(projectedNodeID)) + continuation.yield(.inspect(nodeID)) } catch { logger.debug( - "Inspector.inspect requestNode failed objectID=\(objectID.rawValue, privacy: .public) commandTarget=\(targets.commandTargetID.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)" + "Inspector.inspect requestNode failed objectID=\(objectID.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: @@ -599,11 +563,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), @@ -616,19 +579,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 @@ -757,15 +707,11 @@ 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 { diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift index aed0fdf3..4515eadd 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift @@ -24,7 +24,6 @@ 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 @@ -32,14 +31,12 @@ package struct WebInspectorProxyCommand: Se package init( targetID: WebInspectorTarget.ID, route: RoutingTargetID, - resultTargetScopeRawValue: String? = nil, domain: WebInspectorProxyDomain, method: String, payload: Payload ) { self.targetID = targetID self.route = route - self.resultTargetScopeRawValue = resultTargetScopeRawValue self.domain = domain self.method = method self.payload = payload diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 35fec73e..7fcf9b83 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -1402,131 +1402,6 @@ func transportBackedFrameNavigationClearsRestoredPickerHighlight() async throws #expect(context.selectedNode == nil) } -@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" - ) - 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/"]}]}"## - ) - 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}}}"# - ) - await receiveTransportTargetEvent( - transport, - targetID: frameTargetID, - method: "Inspector.inspect", - params: #"{"object":{"type":"object","subtype":"node","objectId":"frame-node-object"}}"# - ) - - let requestNode = try await waitForTransportTargetMessage( - backend, - method: "DOM.requestNode", - after: startupMessageCount - ) - #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}"# - ) - - 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}]}]}]}}"## - ) - - 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 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":[]}"# - ) - - let inlineStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getInlineStylesForNode", - after: startupMessageCount - ) - #expect(inlineStyles.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: inlineStyles.targetIdentifier, - messageID: try transportMessageID(inlineStyles.message), - result: "{}" - ) - - let computedStyle = try await waitForTransportTargetMessage( - backend, - method: "CSS.getComputedStyleForNode", - after: startupMessageCount - ) - #expect(computedStyle.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: computedStyle.targetIdentifier, - messageID: try transportMessageID(computedStyle.message), - result: #"{"computedStyle":[]}"# - ) - - try await waitUntil { context.selectedNode?.elementStyles?.phase == .loaded } -} - @MainActor @Test func transportBackedFrameRuntimeAndConsoleEventsKeepTargetScope() async throws { diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift index fdcc833e..8d2efee8 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift @@ -96,8 +96,7 @@ func inspectorInspectResolvesNodeRemoteObjectToDOMInspectEvent() async throws { kind: .object, subtype: Runtime.Subtype(rawValue: "node") ), - hints: .object([:]), - origin: nil + hints: .object([:]) ), target: target ) @@ -166,8 +165,7 @@ func inspectorInspectIgnoresNonNodeRemoteObject() async throws { id: Runtime.RemoteObject.ID("function-object"), kind: .function ), - hints: .object([:]), - origin: nil + hints: .object([:]) ), target: target ) diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 6a61b77c..8fab3739 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -1104,7 +1104,7 @@ func transportBackendNormalizesInspectorInspectToDOMInspectEvent() async throws } @Test -func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async throws { +func transportBackendProjectsFrameDOMEventsAndRoutesScopedCommands() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) @@ -1116,30 +1116,10 @@ func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async let eventTask = Task { var iterator = target.dom.events.makeAsyncIterator() - let first = await iterator.next() - let second = await iterator.next() - return [first, second].compactMap { $0 } + return await iterator.next() } 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"), @@ -1147,14 +1127,8 @@ func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async 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 { + let event = try await value(of: eventTask) + guard case let .setChildNodes(parentID, nodes)? = event else { Issue.record("Expected frame DOM.setChildNodes to be projected into the current page DOM stream.") return } @@ -1402,12 +1376,12 @@ func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async } @Test -func transportBackendNormalizesParentlessFrameInspectorInspectForCurrentPageRoute() async throws { +func transportBackendIgnoresImpossibleFrameInspectorOrigin() 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() @@ -1421,14 +1395,20 @@ func transportBackendNormalizesParentlessFrameInspectorInspectForCurrentPageRout await waitForEventSubscription(target, domain: .inspector) await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("frame-page-target"), + targetID: ProtocolTarget.ID("frame-target"), method: "Inspector.inspect", params: #"{"object":{"objectId":"remote-frame-node","type":"object","subtype":"node"},"hints":{}}"# ) + await receiveTargetEvent( + transport, + targetID: ProtocolTarget.ID("page-main"), + method: "Inspector.inspect", + params: #"{"object":{"objectId":"remote-main-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 == "remote-main-node") await receiveTargetReply( transport, targetID: requestNode.targetIdentifier, @@ -1438,10 +1418,10 @@ func transportBackendNormalizesParentlessFrameInspectorInspectForCurrentPageRout 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.") + Issue.record("Expected main-page Inspector.inspect to normalize to DOM.inspect.") return } - #expect(nodeID == DOM.Node.ID("42", scopedToTargetRawValue: "frame-page-target")) + #expect(nodeID == DOM.Node.ID("42")) } @Test @@ -1724,7 +1704,7 @@ func transportBackendKeepsBackendResourceIdentifierOnScopedFrameRequests() async } @Test -func transportBackendRoutesRequestNodeThroughPageDOMAgentAndScopesResult() async throws { +func transportBackendRoutesRequestNodeThroughPageDOMAgentWithoutInventingFrameScope() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) @@ -1732,7 +1712,7 @@ 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 target = proxy.frameTarget(id: WebInspectorTarget.ID("frame-target")) let requestNodeTask = Task { try await target.dom.requestNode( @@ -1749,8 +1729,8 @@ func transportBackendRoutesRequestNodeThroughPageDOMAgentAndScopesResult() async result: #"{"nodeId":"frame-node"}"# ) let nodeID = try await requestNodeTask.value - #expect(nodeID == DOM.Node.ID("frame-node", scopedToTargetRawValue: "frame-target")) - #expect(nodeID.targetScopeRawValue == "frame-target") + #expect(nodeID == DOM.Node.ID("frame-node")) + #expect(nodeID.targetScopeRawValue == nil) } @Test From 887e6edddd2d6caa87fcd758bb997f76db62bd1c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:08:36 +0900 Subject: [PATCH 34/81] feat(proxy): bootstrap synchronized DOM model feeds --- Docs/WebInspectorKitsArchitecture.md | 66 +- .../LiveWebInspectorProxyBackend.swift | 8 +- .../ProtocolDOMNodePayload.swift | 8 + .../Transport/ConnectionEventProjection.swift | 10 + .../Transport/ConnectionModelFeed.swift | 11 + .../Transport/TransportReplyStore.swift | 30 + .../Transport/TransportSession.swift | 717 ++++++++++- .../ConnectionModelFeedTests.swift | 1137 +++++++++++++++-- ...torProxyTransportCommandBackendTests.swift | 3 + 9 files changed, 1833 insertions(+), 157 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 93552a64..dc175181 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -137,7 +137,15 @@ External source evidence was read at fixed local revisions: 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`. + `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. - 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. @@ -607,10 +615,15 @@ package enum ModelDomain: Hashable, Sendable { /* configured domains */ } The current transport slice implements the bounded exclusive feed, initial `reset`/`targetSnapshot`, future target lifecycle deltas, future events for -configured domains, transactional capability leases, and enable-replay -boundaries for CSS, Network, Console, and Runtime. Acquisition follows the -deterministic domain order DOM, CSS, Network, Console, Runtime; CSS shares the -DOM prerequisite, while DOM's capability is local and sends no wire command. +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, and `openModelFeed` returns only after every configured capability is active. An acquisition failure or cancellation releases the @@ -627,11 +640,14 @@ 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, non-empty configured-domain `synchronizationComplete`, and the -complete ordered retarget readiness sequence described below remain later -slices. Their record cases define the package contract but are not yet all -emitted. This distinction prevents wire replay completion from being mistaken -for complete model readiness. +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. @@ -686,10 +702,11 @@ 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. -The completed DOM readiness slice will use the same ordered feed even though DOM -has no enable-time replay. After the feed is registered, the core will issue -`DOM.getDocument`. Its -reply arrival sequence becomes a `bootstrapSnapshot` record; DOM mutation +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 @@ -697,8 +714,14 @@ 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` resets the DOM/CSS epochs and starts the same ordered -snapshot bootstrap again rather than exposing an empty tree as ready state. +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. Required-target command failure is terminal. 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. One-off commands internally acquire their declared prerequisites for the duration of the command. Long-lived event/model state holds a structured event @@ -758,12 +781,13 @@ 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 will -eventually perform the following full ordered transition. The current slice +`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, and the -wire capability replay watermarks described above; DOM bootstrap and binding -readiness remain later slices: +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; diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index e8e9f6be..a902a011 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -673,8 +673,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) @@ -747,10 +747,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/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/Transport/ConnectionEventProjection.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift index c5419f56..0b2cf5be 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift @@ -1,6 +1,16 @@ 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, diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index 940b7811..b800c584 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -22,6 +22,17 @@ package enum ModelDomain: Hashable, Sendable { 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: diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index 72a5300e..2027e578 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -9,6 +9,13 @@ extension TransportSession { generation: WebInspectorPage.Generation, operationID: UInt64 ) + case modelBootstrap( + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + targetID: ProtocolTarget.ID, + documentEpoch: ModelDocumentEpoch, + operationID: UInt64 + ) } let purpose: Purpose @@ -69,6 +76,29 @@ extension TransportSession { 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: Hashable, Sendable { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 2cfdc6c8..1afb851e 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -6,7 +6,7 @@ private struct ConnectionPendingReplyOwnership: Equatable, Sendable { let purpose: TransportSession.PendingReply.Purpose } -private struct ConnectionCapabilityCommandOperation: Sendable { +private struct ConnectionOwnedCommandOperation: Sendable { let backend: any TransportBackend let message: String let promise: ReplyPromise @@ -41,6 +41,13 @@ private struct ConnectionCapabilityTask: Sendable { let 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] = [] @@ -51,9 +58,42 @@ private struct ConnectionModelFeedCapabilityLease: Sendable { 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) } @@ -65,11 +105,14 @@ private struct ConnectionModelFeedRegistration: Sendable { var capabilityLeases: [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] = [] } private struct RootEventMutation: Sendable { @@ -140,6 +183,7 @@ package actor ConnectionCore { let activationWaiters: [ReplyPromise] let releaseWaiters: [ReplyPromise] let capabilityTasks: [Task] + let modelBootstrapTasks: [Task] let closeAction: CloseAction func run() async { @@ -162,6 +206,9 @@ package actor ConnectionCore { for task in capabilityTasks { await task.value } + for task in modelBootstrapTasks { + await task.value + } await closeAction() } } @@ -186,6 +233,9 @@ package actor ConnectionCore { private var replayWasTaintedByDirectConsumer: Bool private var capabilities: ConnectionCapabilityRegistry private var capabilityTasks: [UInt64: ConnectionCapabilityTask] + private var nextModelBootstrapOperationID: UInt64 + private var modelBootstrapTasks: [UInt64: ConnectionModelBootstrapTask] + private var modelDocumentEpochs: [ProtocolTarget.ID: ModelDocumentEpoch] private var currentPageGeneration: WebInspectorPage.Generation private var currentPageBindingGapIsOpen: Bool private var eventScopeRegistrationWaiters: [( @@ -234,6 +284,9 @@ package actor ConnectionCore { replayWasTaintedByDirectConsumer = false capabilities = ConnectionCapabilityRegistry() capabilityTasks = [:] + nextModelBootstrapOperationID = 0 + modelBootstrapTasks = [:] + modelDocumentEpochs = [:] currentPageGeneration = WebInspectorPage.Generation(rawValue: 0) currentPageBindingGapIsOpen = false eventScopeRegistrationWaiters = [] @@ -274,6 +327,17 @@ package actor ConnectionCore { ) } } + 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." + ) + } + } 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.") @@ -340,6 +404,7 @@ package actor ConnectionCore { throw ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer } + let configuredDomains = ModelDomain.normalized(configuredDomains) let id = ConnectionModelFeedID() let mailbox = ConnectionModelFeedMailbox(capacity: capacity) let feed = ConnectionModelFeed(id: id, owner: self, mailbox: mailbox) @@ -358,7 +423,9 @@ package actor ConnectionCore { lifecycle: .acquiring, capabilityLeases: [], targetSnapshotThrough: nil, - resetGeneration: resetGeneration + resetGeneration: resetGeneration, + synchronization: nil, + domBootstrap: nil ) guard enqueueModelFeedRecord(.reset(resetGeneration)) else { @@ -437,6 +504,8 @@ package actor ConnectionCore { 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.value() case .active: @@ -446,6 +515,7 @@ package actor ConnectionCore { let completion = ReplyPromise() registration.lifecycle = .closing(completion) modelFeed = registration + await cancelAndAwaitModelBootstrapTasks(feedID: id) var cleanupError: (any Swift.Error)? for lease in registration.capabilityLeases.reversed() { @@ -508,10 +578,16 @@ package actor ConnectionCore { private func rollbackModelFeedAcquisition( _ id: ConnectionModelFeedID ) async -> (any Swift.Error)? { - guard let registration = modelFeed, + 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 + await cancelAndAwaitModelBootstrapTasks(feedID: id) var cleanupError: (any Swift.Error)? for lease in registration.capabilityLeases.reversed() { do { @@ -562,6 +638,27 @@ package actor ConnectionCore { } } + 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 @@ -1000,19 +1097,452 @@ package actor ConnectionCore { 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 registration.configuredDomains.isEmpty else { + 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 } - return enqueueModelFeedRecord( + 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() -> Bool { + guard var registration = modelFeed, + var synchronization = registration.synchronization else { + 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: currentPageGeneration, - through: through + generation: synchronization.generation, + through: eventSequences.current.sequence + ) + ) else { + return false + } + synchronization.didPublish = true + registration.synchronization = synchronization + modelFeed = registration + 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) + } + 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 + } + 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 + ) { + guard event.method == "DOM.documentUpdated", + var registration = modelFeed, + var bootstrap = registration.domBootstrap, + bootstrap.generation == currentPageGeneration else { + return + } + let targetID = event.sourceTargetID + ?? event.targetID + ?? targetRegistry.currentMainPageTargetID + guard let targetID, + bootstrap.targetsByID[targetID] != nil else { + return + } + _ = advanceModelDocumentEpoch(for: targetID) + bootstrap.needsCompletionMarker = true + registration.domBootstrap = bootstrap + modelFeed = registration + } + private func publishModelTargetLifecycleEvent( _ event: ModelTargetLifecycleEvent, sequence: UInt64 @@ -1265,7 +1795,7 @@ package actor ConnectionCore { generation: WebInspectorPage.Generation, action: CapabilityWireAction ) { - let operation: ConnectionCapabilityCommandOperation + let operation: ConnectionOwnedCommandOperation do { operation = try makeCapabilityCommandOperation( action, @@ -1322,7 +1852,7 @@ package actor ConnectionCore { for key: ConnectionCapabilityKey, generation: WebInspectorPage.Generation, operationID: UInt64 - ) throws -> ConnectionCapabilityCommandOperation { + ) throws -> ConnectionOwnedCommandOperation { let method: String switch action { case .enable: @@ -1402,7 +1932,7 @@ package actor ConnectionCore { timeoutAction = nil } - return ConnectionCapabilityCommandOperation( + return ConnectionOwnedCommandOperation( backend: backend, message: wrapperMessage, promise: promise, @@ -2076,7 +2606,16 @@ package actor ConnectionCore { receivedDomainSequences: eventSequence.receivedDomainSequences, resultData: parsed.resultData ) - processSuccessfulReply(result, for: pending) + do { + try processSuccessfulReply(result, for: pending) + } catch { + let message = "Failed to decode \(pending.method) reply: \(error)" + handoffTermination(.protocolViolation(message)) + await pending.promise.fulfill( + .failure(WebInspectorProxyError.protocolViolation(message)) + ) + return + } await pending.promise.fulfill( .success(result) ) @@ -2101,6 +2640,18 @@ package actor ConnectionCore { 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." + ) } } @@ -2111,7 +2662,7 @@ package actor ConnectionCore { private func processSuccessfulReply( _ result: ProtocolCommand.Result, for pending: TransportSession.PendingReply - ) { + ) throws { switch pending.purpose { case .client: // Client replies have no internal publication side effect. @@ -2124,7 +2675,92 @@ package actor ConnectionCore { 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 + ) + } + } + + 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( @@ -2154,6 +2790,10 @@ package actor ConnectionCore { 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)) @@ -2175,6 +2815,10 @@ package actor ConnectionCore { ) 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, @@ -2186,6 +2830,9 @@ package actor ConnectionCore { // ownership for overflow or a terminated consumer. return } + guard completeModelDomain(domain, generation: generation) else { + return + } } } @@ -2301,6 +2948,7 @@ package actor ConnectionCore { sequence: eventSequence ) } + reconcileDOMBootstrapTargets() guard isOpen else { return RootEventMutation() } @@ -2342,6 +2990,7 @@ package actor ConnectionCore { targetRegistry.isCurrentPageModelTarget ) ?? false targetRegistry.removeTarget(targetID) + modelDocumentEpochs.removeValue(forKey: targetID) modelTargetMutationActionForTesting?() let capabilityWaiters = physicalTargetDidDisappear(targetID) let pendingReplies = replyStore.removeTargetReplies(for: targetID) @@ -2360,6 +3009,7 @@ package actor ConnectionCore { sequence: eventSequence ) } + reconcileDOMBootstrapTargets() return RootEventMutation( bindingEffects: bindingEffects, physicalTargetWaiters: capabilityWaiters, @@ -2403,6 +3053,8 @@ package actor ConnectionCore { sequence: eventSequence ) } + modelDocumentEpochs.removeValue(forKey: oldTargetID) + reconcileDOMBootstrapTargets() guard isOpen else { return RootEventMutation() } @@ -2423,6 +3075,10 @@ package actor ConnectionCore { return CurrentPageBindingChangeEffects() } + let obsoleteBootstrapTasks = cancelModelBootstrapTasks( + generation: currentPageGeneration + ) + if oldTargetID == nil, newTargetID != nil, currentPageBindingGapIsOpen { @@ -2439,7 +3095,8 @@ package actor ConnectionCore { } return CurrentPageBindingChangeEffects( capabilityKeysToReconcile: keys, - releaseWaiters: [] + releaseWaiters: [], + modelBootstrapTasksToAwait: obsoleteBootstrapTasks ) } @@ -2469,13 +3126,17 @@ package actor ConnectionCore { return CurrentPageBindingChangeEffects( capabilityKeysToReconcile: newTargetID == nil ? [] : keys, - releaseWaiters: releaseWaiters + releaseWaiters: releaseWaiters, + modelBootstrapTasksToAwait: obsoleteBootstrapTasks ) } private func completeCurrentPageBindingChange( _ effects: CurrentPageBindingChangeEffects ) async { + for task in effects.modelBootstrapTasksToAwait { + await task.value + } for key in effects.capabilityKeysToReconcile { await reconcileCapability(for: key) if !isOpen { @@ -2487,6 +3148,21 @@ package actor ConnectionCore { } } + 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 { @@ -2514,6 +3190,8 @@ package actor ConnectionCore { if registration.targetSnapshotThrough != nil { registration.targetSnapshotThrough = nil registration.resetGeneration = currentPageGeneration + registration.synchronization = nil + registration.domBootstrap = nil modelFeed = registration guard enqueueModelFeedRecord(.reset(currentPageGeneration)) else { return @@ -2791,6 +3469,10 @@ package actor ConnectionCore { paramsData: paramsData, destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget ) + // WebKit invalidates every bound node identifier before emitting + // documentUpdated. Advance the target epoch before this event can be + // projected into the ordered model feed. + prepareDOMDocumentUpdateForModelFeed(envelope) if let eventDomain = webInspectorEventDomain(for: domain) { var terminalViolation: String? let targetSnapshot = snapshot() @@ -2880,6 +3562,7 @@ package actor ConnectionCore { ) return nil } + startNextDOMBootstrapIfNeeded() guard isOpen else { return nil } @@ -3222,6 +3905,11 @@ package actor ConnectionCore { 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 { @@ -3243,6 +3931,7 @@ package actor ConnectionCore { activationWaiters: activationWaiters, releaseWaiters: releaseWaiters, capabilityTasks: runningCapabilityTasks, + modelBootstrapTasks: runningModelBootstrapTasks, closeAction: closeAction ) } diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index a04ebb8f..05e80b4a 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -56,6 +56,128 @@ func modelTargetSnapshotOrdersMainAndCommittedFramesDeterministically() throws { ]) } +@Test +func modelDomainNormalizationAddsDOMOnlyWhenCSSRequiresIt() { + #expect(ModelDomain.normalized([.css]) == [.dom, .css]) + #expect(ModelDomain.normalized([.network]) == [.network]) + #expect(ModelDomain.normalized([.dom, .runtime]) == [.dom, .runtime]) +} + +@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: ["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], + capacity: 12 + ) + } + 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) @@ -156,13 +278,19 @@ func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWaterma 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", @@ -239,6 +367,7 @@ func modelFeedReplayCompletionFollowsEnableTimeEventsBeforeOpenReturns() async t 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) @@ -251,6 +380,8 @@ func modelFeedReplayCompletionFollowsEnableTimeEventsBeforeOpenReturns() async t #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, @@ -715,12 +846,21 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as let reset = try await modelFeedRequireReset(iterator.next()) let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) #expect(snapshot.generation == reset) - let replayCompletions = try await [ - modelFeedRequireReplayCompletion(iterator.next()), - modelFeedRequireReplayCompletion(iterator.next()), - modelFeedRequireReplayCompletion(iterator.next()), - modelFeedRequireReplayCompletion(iterator.next()), - ] + 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, @@ -730,6 +870,13 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as #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( @@ -907,8 +1054,7 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( for index in 0...failureIndex { let message = try await backend.waitForTargetMessage( - ordinal: 0, - after: index + method: enableMethods[index] ) #expect(try modelFeedMessageMethod(message.message) == enableMethods[index]) if index == failureIndex { @@ -925,10 +1071,9 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( let rollbackMethods = Array(enableMethods[..= firstUpdate.sequence) + let initialCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) + let initialSync = try await modelFeedRequireSynchronization(iterator.next()) + #expect(initialCompletion.through == initialBootstrap.sequence) + #expect(initialSync.generation == targetSnapshot.generation) + + let secondUpdateSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + ) + ) + let secondUpdate = try await modelFeedRequireEvent(iterator.next()) + #expect(secondUpdate.sequence == secondUpdateSequence) + let replacementDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 2 ) + await modelFeedRespondWithDocument(to: replacementDocument, core: core, nodeID: "replacement") + let replacementBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let replacementCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) + #expect(replacementBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 2)) + #expect(replacementBootstrap.root.id == DOM.Node.ID("replacement")) + #expect(replacementCompletion.through == replacementBootstrap.sequence) - await #expect(throws: WebInspectorScopeError.self) { - try await openTask.value - } - 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: [], capacity: 8) - } + try await feed.close() + // A post-synchronization epoch refresh gets a bootstrap boundary, but the + // binding generation remains synchronized exactly once. + #expect(try await iterator.next() == nil) + await core.close() } @Test -func modelFeedCloseDisableRejectionPoisonsMailboxAndTerminatesConnection() async throws { +func domBootstrapTracksTargetsAddedAndDestroyedBeforeInitialSync() async throws { let backend = FakeTransportBackend() let core = ConnectionCore(backend: backend, responseTimeout: nil) _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( @@ -1304,59 +1533,644 @@ func modelFeedCloseDisableRejectionPoisonsMailboxAndTerminatesConnection() async type: "page", frameID: "main-frame" )) - let feed = try await modelFeedOpenSuccessfully( - core: core, - backend: backend, - configuredDomains: [.network], - targetID: "page-main" + + let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 24) + let mainDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 ) var iterator = feed.records.makeAsyncIterator() - _ = try await iterator.next() - _ = try await iterator.next() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) - let closeTask = Task { - try await feed.close() + _ = 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 } - let disable = try await backend.waitForTargetMessage(method: "Network.disable") - await modelFeedRespond( - to: disable, - core: core, - errorMessage: "disable rejected" + #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 ) - let expectedError = WebInspectorProxyError.commandRejected( - method: "Network.disable", - message: "disable rejected" + #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 ) - await #expect(throws: expectedError) { - try await closeTask.value - } - await #expect(throws: expectedError) { - try await iterator.next() - } + #expect(frameBDocument.targetIdentifier == ProtocolTarget.ID("frame-b")) - guard case let .fatal(message) = await core.terminalCause else { - Issue.record("Expected model feed close cleanup failure to terminate the connection.") + _ = 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(message.contains("Failed to release model feed capabilities")) - #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) - #expect(await backend.isDetached()) + #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() } -private func modelFeedExpectedEnableMethods( - _ configuredDomains: Set -) -> [String] { - var seenDomains: Set = [] - var methods: [String] = [] - for domain in ModelDomain.ordered(configuredDomains) { - for dependency in domain.capabilityDependencies where dependency != .dom { - guard seenDomains.insert(dependency).inserted else { - continue - } - methods.append("\(dependency.rawValue).enable") - } - } +@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], capacity: 12) + 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 domBootstrapSnapshotOverflowCannotAdvanceToNextTarget() 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], capacity: 2) + let mainDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + await modelFeedRespondWithDocument(to: mainDocument, core: core) + + var iterator = feed.records.makeAsyncIterator() + await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { + try await iterator.next() + } + #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + let getDocumentMessages = try await backend.sentTargetMessages().filter { + try modelFeedMessageMethod($0.message) == "DOM.getDocument" + } + #expect(getDocumentMessages.map(\.targetIdentifier) == [ + ProtocolTarget.ID("page-main"), + ]) +} + +@Test +func domBootstrapCompletionOverflowTerminatesAtTheBoundary() 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], capacity: 2) + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + + // targetSnapshot remains queued. bootstrapSnapshot fills the second slot, + // so bootstrapComplete is the exact overflowing publication. + await modelFeedRespondWithDocument(to: getDocument, core: core) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) + await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { + try await iterator.next() + } + #expect(try await modelFeedDOMGetDocumentMessages(backend).count == 1) +} + +@Test +func domSynchronizationOverflowTerminatesAtTheBoundary() 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], capacity: 2) + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + // The accepted snapshot and bootstrapComplete fill the mailbox, making + // synchronizationComplete the exact overflowing publication. + await modelFeedRespondWithDocument(to: getDocument, core: core) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) + await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { + try await iterator.next() + } + #expect(try await modelFeedDOMGetDocumentMessages(backend).count == 1) +} + +@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], + capacity: 8 + ) + 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], capacity: 8) + 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" + ) + + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + guard case let .fatal(message) = await core.terminalCause else { + Issue.record("Expected a required bootstrap failure to terminate the connection.") + return + } + #expect(message.contains("DOM.getDocument failed for required target page-main")) + await #expect(throws: WebInspectorProxyError.self) { + 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], capacity: 8) + 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], capacity: 8) + _ = 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: [], capacity: 3) + 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], + capacity: 24 + ) + } + 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: WebInspectorProxyError.commandRejected( + method: "Console.enable", + 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], + capacity: 16 + ) + } + 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 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], + capacity: 16 + ) + } + 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" + ) + + await #expect(throws: WebInspectorScopeError.self) { + try await openTask.value + } + 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: [], capacity: 8) + } +} + +@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() +} + +private func modelFeedExpectedEnableMethods( + _ configuredDomains: Set +) -> [String] { + var seenDomains: Set = [] + var methods: [String] = [] + for domain in ModelDomain.ordered(configuredDomains) { + for dependency in domain.capabilityDependencies where dependency != .dom { + guard seenDomains.insert(dependency).inserted else { + continue + } + methods.append("\(dependency.rawValue).enable") + } + } return methods } @@ -1366,7 +2180,8 @@ private func modelFeedOpenSuccessfully( configuredDomains: Set, targetID: String ) async throws -> ConnectionModelFeed { - let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) + let normalizedDomains = ModelDomain.normalized(configuredDomains) + let enableMethods = modelFeedExpectedEnableMethods(normalizedDomains) let targetMessageCount = await backend.sentTargetMessages().count let openTask = Task { try await core.openModelFeed( @@ -1374,10 +2189,18 @@ private func modelFeedOpenSuccessfully( capacity: 32 ) } - for (offset, expectedMethod) in enableMethods.enumerated() { + if normalizedDomains.contains(.dom) { let message = try await backend.waitForTargetMessage( - ordinal: 0, - after: targetMessageCount + offset + 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)) @@ -1415,8 +2238,17 @@ private func modelFeedCloseSuccessfully( private func modelFeedSentTargetMethods( _ backend: FakeTransportBackend ) async throws -> [String] { - try await backend.sentTargetMessages().map { - try modelFeedMessageMethod($0.message) + 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" } } @@ -1472,6 +2304,33 @@ private func modelFeedRespond( )) } +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 @@ -1495,6 +2354,20 @@ private struct ModelFeedReplayCompletionRecord { 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 } @@ -1547,6 +2420,38 @@ private func modelFeedRequireReplayCompletion( ) } +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 { diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 8fab3739..49ddb1f5 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -4155,6 +4155,9 @@ private func requireCapabilityReplyPurpose( case .client: 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) From 94750e64fab6fb19dad3a935ed34b8dea8fec916 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:59:40 +0900 Subject: [PATCH 35/81] feat(data)!: add concrete Network and Console queries --- .../ContractTestSupport.swift | 22 +- .../StoryADataKitImportOnlyContract.swift | 18 +- Docs/WebInspectorKitsArchitecture.md | 129 +++- .../ConsoleMessageIndex.swift | 499 ++++++++++++-- .../ConsoleMessageStore.swift | 481 +++++++++++++- .../WebInspectorDataKit/ConsoleQuery.swift | 69 ++ Sources/WebInspectorDataKit/Fetching.swift | 316 ++++++++- .../WebInspectorDataKit/NetworkQuery.swift | 94 +++ .../NetworkRequestIndex.swift | 538 +++++++++++++-- .../NetworkRequestStore.swift | 463 ++++++++++++- .../WebInspectorContext.swift | 157 ++++- .../WebInspectorDataKit.md | 30 + .../WebInspectorQueryRegistration.swift | 45 ++ .../List/NetworkListOverflowMenuView.swift | 4 +- .../NetworkPanelModel.swift | 4 +- .../ConsoleConcreteQueryTests.swift | 307 +++++++++ .../ConsoleMessageStoreTests.swift | 2 +- .../NetworkConcreteQueryTests.swift | 625 ++++++++++++++++++ .../NetworkRequestStoreTests.swift | 2 +- .../WebInspectorDataKitTests.swift | 86 ++- .../NetworkDetailViewControllerTests.swift | 13 +- .../NetworkPanelModelTests.swift | 8 +- 22 files changed, 3700 insertions(+), 212 deletions(-) create mode 100644 Sources/WebInspectorDataKit/ConsoleQuery.swift create mode 100644 Sources/WebInspectorDataKit/NetworkQuery.swift create mode 100644 Sources/WebInspectorDataKit/WebInspectorQueryRegistration.swift create mode 100644 Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift create mode 100644 Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index 18f7071c..cea1651f 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -197,14 +197,34 @@ actor ContractDataKitActor { context.fetchedResults(sectionBy: \.method) let sectionedConsole: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) + let concreteRequests = try await context.networkRequests(matching: NetworkQuery( + search: " contract ", + resourceCategories: [.xhrFetch], + methods: ["GET"], + sort: .requestTimeAscending, + section: .method, + offset: 0, + limit: 10 + )) + let concreteConsole = try await context.consoleMessages(matching: ConsoleQuery( + levels: [Console.Level(rawValue: "warning")], + sort: .insertionDescending, + section: .level, + offset: 0, + limit: 10 + )) #expect(requestResults.items.isEmpty) #expect(consoleResults.items.isEmpty) #expect(sectionedRequests.sections.isEmpty) #expect(sectionedConsole.sections.isEmpty) + #expect(concreteRequests.items.isEmpty) + #expect(concreteConsole.items.isEmpty) #expect(requestResults.snapshot.itemIDs.isEmpty) #expect(consoleResults.snapshot.itemIDs.isEmpty) _ = requestResults.updates() _ = consoleResults.updates() + try await concreteRequests.update(NetworkQuery(sort: .requestTimeDescending)) + try await concreteConsole.update(ConsoleQuery(sort: .insertionAscending)) #expect(context.state == .attached) let root = try #require(context.rootNode) @@ -225,7 +245,7 @@ actor ContractDataKitActor { #expect(context.selectedNode === root) context.select(nil) context.selectContext(nil) - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(context.selectedNode == nil) #expect(context.selectedContext == nil) } diff --git a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift index a15469c5..07e453e5 100644 --- a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift +++ b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift @@ -14,6 +14,18 @@ private actor DataKitImportOnlyActor { context.fetchedResults(sectionBy: \.method) let messagesByLevel: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) + let concreteRequests = try await context.networkRequests(matching: NetworkQuery( + search: " import-only ", + methods: ["GET"], + sort: .requestTimeDescending, + section: .method, + limit: 10 + )) + let concreteMessages = try await context.consoleMessages(matching: ConsoleQuery( + sort: .insertionDescending, + section: .level, + limit: 10 + )) _ = context.state _ = context.rootNode?.children _ = context.selectedNode?.attributes @@ -22,7 +34,7 @@ private actor DataKitImportOnlyActor { _ = context.selectedNode?.elementStyles?.sections.first?.style.properties.first?.name _ = context.selectedNode?.elementStyles?.computedProperties.first?.value _ = context.isElementPickerEnabled - context.clearNetworkRequests() + await context.clearNetworkRequests() let treeController = try await context.treeController() let treeSnapshot: DOMTreeSnapshot = treeController.snapshot _ = treeSnapshot.rootNodeID @@ -58,6 +70,8 @@ private actor DataKitImportOnlyActor { ) _ = requests.revision _ = requests.updates() + _ = concreteRequests.snapshot + try await concreteRequests.update(NetworkQuery()) _ = requestTransaction.hasChanges _ = messages.items.first?.text _ = messages.items.first?.parameters.first?.description @@ -65,6 +79,8 @@ private actor DataKitImportOnlyActor { _ = messages.snapshot _ = messages.revision _ = messages.updates() + _ = concreteMessages.snapshot + try await concreteMessages.update(ConsoleQuery()) _ = try await context.evaluate("1 + 1").object.description let request = NetworkRequestSnapshot(url: "https://example.com", method: "GET") diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index dc175181..d66e72be 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1019,7 +1019,7 @@ public final class WebInspectorModelContext { ) async throws -> WebInspectorFetchedResults public func networkRequest(id: NetworkRequest.ID) throws -> NetworkRequest? - public func clearNetworkRequests() throws + public nonisolated(nonsending) func clearNetworkRequests() async public nonisolated(nonsending) func clearConsoleMessages() async throws public nonisolated(nonsending) func responseBody( for request: NetworkRequest @@ -1270,15 +1270,19 @@ paths: ```swift public struct NetworkQuery: Sendable, Equatable { public var search: String? - public var resourceCategories: Set? + 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? = nil, + resourceCategories: Set = [], + methods: Set = [], sort: NetworkSort = .requestTimeDescending, + section: NetworkSection? = nil, offset: Int = 0, limit: Int? = nil ) @@ -1289,6 +1293,35 @@ public enum NetworkSort: Sendable, Equatable { 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? @@ -1343,8 +1376,11 @@ public extension WebInspectorFetchedResults where Model == ConsoleMessage { } ``` -`ConsoleQuery` follows the same closed pattern with supported message filters, -ordering, and grouping. `WebInspectorFetchedResults` stores items, sections, +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. @@ -1383,24 +1419,55 @@ 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, maintains registered query membership/order, and returns identity -snapshots and deltas; query replacement may perform a full scan on that actor, -but a live insert must not dereference or filter every identity model on its -owner actor. -The index is also the sole owner of each identity's last mutation sequence. -Replace and upsert operations enter one sequence-keyed queue; the index drains -only the next contiguous operation, and each caller waits until its operation -has been applied. Cross-actor job scheduling therefore cannot create a hole in -the scalar high-water checkpoint. -Every result keeps its own successfully applied index checkpoint, and a delta -contains all still-visible identities changed after that checkpoint. Therefore, -if an overlapping query is discarded because a newer index sequence arrived, -the next delta recovers its property-only reconfiguration work instead of only -reporting the mutation that happened to trigger the newer query. Query -replacement and reset establish the checkpoint explicitly; stale or duplicate -deltas cannot advance it. +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 @@ -1408,6 +1475,22 @@ 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 diff --git a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift index 10650396..16b7806d 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -7,35 +7,80 @@ package struct ConsoleResultSetDelta: Sendable { package var reconfigureItemIDs: Set } -/// Owns compact Console query records away from the model-context actor. +/// 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]) + 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() {} - package func replace(with inputs: [ConsoleMessageRecordInput], sequence: UInt64) async { - await enqueue(.replace(inputs), sequence: sequence) + @discardableResult + package func replace( + with inputs: [ConsoleMessageRecordInput], + sequence: UInt64, + sourceEpoch: UInt64? = nil + ) async -> [QueryDelivery] { + await enqueue(.replace(inputs, sourceEpoch: sourceEpoch), sequence: sequence) } - package func upsert(_ input: ConsoleMessageRecordInput, sequence: UInt64) async { + @discardableResult + package func upsert( + _ input: ConsoleMessageRecordInput, + sequence: UInt64 + ) async -> [QueryDelivery] { await enqueue(.upsert(input), sequence: sequence) } - private func enqueue(_ mutation: Mutation, sequence: UInt64) async { + private func enqueue(_ mutation: Mutation, sequence: UInt64) async -> [QueryDelivery] { precondition( sequence > lastAppliedSequence, "ConsoleMessageIndex received an already-applied mutation sequence." @@ -44,7 +89,7 @@ package actor ConsoleMessageIndex { pendingMutationsBySequence[sequence] == nil, "ConsoleMessageIndex received a duplicate mutation sequence." ) - await withCheckedContinuation { continuation in + return await withCheckedContinuation { continuation in pendingMutationsBySequence[sequence] = PendingMutation( mutation: mutation, continuation: continuation @@ -57,21 +102,30 @@ package actor ConsoleMessageIndex { while lastAppliedSequence < UInt64.max { let sequence = lastAppliedSequence + 1 guard let pending = pendingMutationsBySequence.removeValue(forKey: sequence) else { + resumeSequenceWaiters() return } - apply(pending.mutation, sequence: sequence) + let deliveries = apply(pending.mutation, sequence: sequence) lastAppliedSequence = sequence - pending.continuation.resume() + pending.continuation.resume(returning: deliveries) } precondition( pendingMutationsBySequence.isEmpty, "ConsoleMessageIndex mutation sequence overflowed." ) + resumeSequenceWaiters() } - private func apply(_ mutation: Mutation, sequence: UInt64) { + private func apply(_ mutation: Mutation, sequence: UInt64) -> [QueryDelivery] { switch mutation { - case let .replace(inputs): + 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 = [] @@ -81,8 +135,14 @@ package actor ConsoleMessageIndex { 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 + ) } } @@ -96,6 +156,367 @@ package actor ConsoleMessageIndex { } } + package func register( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: ConsoleQuery, + lifetime: WebInspectorQueryRegistrationLifetime, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + 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 { + 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 { + guard lastAppliedSequence < minimumSequence else { + return + } + precondition( + nextSequenceWaiterID < UInt64.max, + "ConsoleMessageIndex sequence waiter identity overflowed." + ) + let waiterID = nextSequenceWaiterID + nextSequenceWaiterID += 1 + await withCheckedContinuation { continuation in + sequenceWaiters[waiterID] = SequenceWaiter( + minimumSequence: minimumSequence, + continuation: continuation + ) + resumeSequenceWaiters() + } + } + + 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() + } + } + + 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..?, @@ -104,7 +525,7 @@ package actor ConsoleMessageIndex { ) -> ConsoleResultSetDelta { precondition(plan.requiresModelQuery == false) let indexSequence = lastAppliedSequence - let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) + let newSnapshot = legacySnapshot(plan: plan, sectionBy: sectionBy) let oldItemIDs = Set(oldSnapshot.itemIDs) let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence @@ -122,39 +543,28 @@ package actor ConsoleMessageIndex { ) } -#if DEBUG - package func isMutationPendingForTesting(sequence: UInt64) -> Bool { - pendingMutationsBySequence[sequence] != nil - } -#endif - - private func snapshot( + private func legacySnapshot( plan: ConsoleMessageQueryPlan, sectionBy: WebInspectorSectionDescriptor? ) -> WebInspectorFetchedResultsSnapshot { - let matchingRecords = visibleRecords(plan: plan) + let matchingRecords = legacyVisibleRecords(plan: plan) guard matchingRecords.isEmpty == false else { return WebInspectorFetchedResultsSnapshot() } guard let sectionBy else { return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) } - var sections: [( id: WebInspectorFetchSectionID, title: String?, itemIDs: [ConsoleMessage.ID] )] = [] for record in matchingRecords { - let identity = sectionIdentity(for: record, sectionBy: sectionBy) + let identity = legacySectionIdentity(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] - )) + sections.append((identity.id, identity.title, [record.id])) } } return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in @@ -166,36 +576,19 @@ package actor ConsoleMessageIndex { }) } - private func visibleRecords(plan: ConsoleMessageQueryPlan) -> [ConsoleMessageRecord] { - var records: [ConsoleMessageRecord] = [] - 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) + private func legacyVisibleRecords(plan: ConsoleMessageQueryPlan) -> [ConsoleMessageRecord] { + var records = orderedIDs.compactMap { recordsByID[$0] }.filter { + plan.matches(record: $0) == true } - if plan.sortComparators.isEmpty == false { - records.sort { lhs, rhs in - plan.ordersBefore(lhs, rhs) - } + records.sort { plan.ordersBefore($0, $1) } } - 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 - } + let upperBound = plan.fetchLimit.map { min(lowerBound + $0, records.count) } ?? records.count return Array(records[lowerBound.. ) -> (id: WebInspectorFetchSectionID, title: String?) { @@ -209,13 +602,9 @@ package actor ConsoleMessageIndex { value = record.kindRawValue case .consoleURL: value = record.url - case .networkMethod, - .networkResourceType, - .networkResourceCategory, - .networkMIMEType: + case .networkMethod, .networkResourceType, .networkResourceCategory, .networkMIMEType: preconditionFailure("Network section descriptors cannot be applied to ConsoleMessage results.") } - let title = value ?? "" return (WebInspectorFetchSectionID(rawValue: title), title) } diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift index 93c45cb7..0acf5756 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -6,6 +6,25 @@ import WebInspectorProxyKit /// `WebInspectorContext`; clear operations return explicit effects for that /// owner to apply. The store never retains an actor token or starts a task. package final class ConsoleMessageStore { + 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 enum RuntimeObjectGroupRelease: Equatable { case currentPage case target(WebInspectorTarget.ID) @@ -17,6 +36,12 @@ package final class ConsoleMessageStore { package var runtimeObjectGroupRelease: RuntimeObjectGroupRelease? } + package struct QueryIndexReset: Sendable { + fileprivate var inputs: [ConsoleMessageRecordInput] + fileprivate var sequence: UInt64 + fileprivate var sourceEpoch: UInt64 + } + #if DEBUG package struct PerformanceCounters: Equatable { package var fullModelProjectionCount = 0 @@ -36,6 +61,14 @@ package final class ConsoleMessageStore { private var queryIndexSequence: UInt64 private var queryIndexNeedsRebuild: Bool private var fetchedResults: [WeakWebInspectorFetchedResults] + 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 @@ -51,6 +84,10 @@ package final class ConsoleMessageStore { queryIndexSequence = 0 queryIndexNeedsRebuild = false fetchedResults = [] + querySourceEpoch = 0 + nextConcreteQueryRegistrationID = 0 + initializingConcreteQueries = [:] + concreteQueryRegistrations = [:] #if DEBUG performanceCounters = PerformanceCounters() #endif @@ -92,6 +129,170 @@ package final class ConsoleMessageStore { fetchedResults.append(WeakWebInspectorFetchedResults(results)) } + package func results( + matching query: ConsoleQuery, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) async throws -> WebInspectorFetchedResults { + await syncQueryIndexIfNeeded(isolation: isolation) + let id = allocateConcreteQueryRegistrationID(isolation: isolation) + let lifetime = WebInspectorQueryRegistrationLifetime() + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + 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, isolation: isolation) } + ) + 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 func update( + _ query: ConsoleQuery, + for results: WebInspectorFetchedResults, + isolation: isolated (any Actor) = #isolation + ) async throws { + guard let id = results.concreteQueryRegistrationID else { + preconditionFailure("Console fetched results are not registered in this store.") + } + await syncQueryIndexIfNeeded(isolation: isolation) + 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, isolation: isolation) } + ) + 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 updateFetchDescriptor( _ descriptor: WebInspectorFetchDescriptor, for results: WebInspectorFetchedResults, @@ -136,7 +337,7 @@ package final class ConsoleMessageStore { ) return Effects() case .messagesCleared: - return clear(targetID: targetID, modelContext: modelContext, isolation: isolation) + return await clear(targetID: targetID, modelContext: modelContext, isolation: isolation) case .unknown: return Effects() } @@ -145,22 +346,38 @@ package final class ConsoleMessageStore { package func resetForReplay( modelContext: WebInspectorContext, isolation: isolated (any Actor) = #isolation - ) { + ) async { _ = removeMessages(targetID: nil, isolation: isolation) refreshAllResults(modelContext: modelContext, isolation: isolation) + let reset = prepareEmptyQueryIndexReset(isolation: isolation) + await finishQueryIndexReset(reset, isolation: isolation) } - package func clearForLifecycle( + package func prepareClearForLifecycle( modelContext: WebInspectorContext, isolation: isolated (any Actor) = #isolation - ) -> Effects { + ) -> (effects: Effects, queryIndexReset: QueryIndexReset) { _ = removeMessages(targetID: nil, isolation: isolation) refreshAllResults(modelContext: modelContext, isolation: isolation) - return Effects( - clearedAllMessages: true + let reset = prepareEmptyQueryIndexReset(isolation: isolation) + return ( + Effects(clearedAllMessages: true), + reset ) } + package func finishQueryIndexReset( + _ reset: QueryIndexReset, + isolation: isolated (any Actor) = #isolation + ) async { + let deliveries = await queryIndex.replace( + with: reset.inputs, + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + await applyConcreteDeliveries(deliveries, isolation: isolation) + } + private func insertMessage( _ payload: Console.Message, parameters: [RuntimeObject], @@ -214,7 +431,7 @@ package final class ConsoleMessageStore { targetID: WebInspectorTarget.ID?, modelContext: WebInspectorContext, isolation: isolated (any Actor) - ) -> Effects { + ) async -> Effects { let removedMessages = removeMessages(targetID: targetID, isolation: isolation) let runtimeObjectsToUnregister = targetID == nil ? [] @@ -223,6 +440,10 @@ package final class ConsoleMessageStore { isolation: isolation ) refreshAllResults(modelContext: modelContext, isolation: isolation) + let reset = targetID == nil + ? prepareEmptyQueryIndexReset(isolation: isolation) + : prepareQueryIndexReset(isolation: isolation) + await finishQueryIndexReset(reset, isolation: isolation) return Effects( runtimeObjectsToUnregister: runtimeObjectsToUnregister, clearedAllMessages: targetID == nil, @@ -236,7 +457,7 @@ package final class ConsoleMessageStore { isolation: isolated (any Actor) ) -> [ConsoleMessage] { _ = isolation - queryIndexNeedsRebuild = true + queryIndexNeedsRebuild = false guard let targetID else { let removedMessages = Array(messagesByID.values) messagesByID = [:] @@ -345,6 +566,60 @@ package final class ConsoleMessageStore { return queryIndexSequence } + private func prepareQueryIndexReset( + isolation: isolated (any Actor) + ) -> QueryIndexReset { + precondition( + querySourceEpoch < UInt64.max, + "Console query source epoch overflowed." + ) + querySourceEpoch += 1 + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence(isolation: isolation) + let inputs = currentRecordInputs(isolation: isolation) + return QueryIndexReset( + inputs: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + } + + private func prepareEmptyQueryIndexReset( + isolation: isolated (any Actor) + ) -> QueryIndexReset { + let reset = prepareQueryIndexReset(isolation: isolation) + publishEmptyConcreteQueryResults(for: reset, isolation: isolation) + return reset + } + + private func publishEmptyConcreteQueryResults( + for reset: QueryIndexReset, + isolation: isolated (any Actor) + ) { + precondition( + reset.inputs.isEmpty, + "Only a full Console lifecycle reset can publish an immediate empty query state." + ) + pruneConcreteQueryRegistrations(isolation: isolation) + 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 func syncQueryIndexIfNeeded(isolation: isolated (any Actor)) async { guard queryIndexNeedsRebuild else { return @@ -352,7 +627,12 @@ package final class ConsoleMessageStore { queryIndexNeedsRebuild = false let sequence = nextQueryIndexSequence(isolation: isolation) let inputs = currentRecordInputs(isolation: isolation) - await queryIndex.replace(with: inputs, sequence: sequence) + let deliveries = await queryIndex.replace( + with: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + await applyConcreteDeliveries(deliveries, isolation: isolation) } private func notifyMessageInserted( @@ -366,7 +646,8 @@ package final class ConsoleMessageStore { } let sequence = nextQueryIndexSequence(isolation: isolation) let input = recordInput(for: message, isolation: isolation) - await queryIndex.upsert(input, sequence: sequence) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries, isolation: isolation) guard isCurrent(message, isolation: isolation) else { return } @@ -389,7 +670,8 @@ package final class ConsoleMessageStore { } let sequence = nextQueryIndexSequence(isolation: isolation) let input = recordInput(for: message, isolation: isolation) - await queryIndex.upsert(input, sequence: sequence) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries, isolation: isolation) guard isCurrent(message, isolation: isolation) else { return } @@ -454,6 +736,9 @@ package final class ConsoleMessageStore { isolation: isolated (any Actor) ) { pruneFetchedResults(isolation: isolation) + guard fetchedResults.isEmpty == false else { + return + } let messages = currentMessages(isolation: isolation) for registration in fetchedResults { guard let results = registration.value else { @@ -469,6 +754,180 @@ package final class ConsoleMessageStore { } } + private func allocateConcreteQueryRegistrationID( + isolation: isolated (any Actor) + ) -> WebInspectorQueryRegistrationID { + _ = isolation + precondition( + nextConcreteQueryRegistrationID < UInt64.max, + "Console concrete query registration identity overflowed." + ) + let id = WebInspectorQueryRegistrationID(rawValue: nextConcreteQueryRegistrationID) + nextConcreteQueryRegistrationID += 1 + return id + } + + private func applyConcreteDeliveries( + _ deliveries: [ConsoleMessageIndex.QueryDelivery], + isolation: isolated (any Actor) + ) async { + pruneConcreteQueryRegistrations(isolation: isolation) + var acknowledgements: [( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + sourceEpoch: UInt64, + sequence: UInt64 + )] = [] + 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, isolation: isolation) } + ) + if applied { + acknowledgements.append(( + id, + delivery.generation, + delivery.projection.sourceEpoch, + 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 + } + for acknowledgement in acknowledgements { + await queryIndex.acknowledge( + id: acknowledgement.id, + generation: acknowledgement.generation, + sourceEpoch: acknowledgement.sourceEpoch, + sequence: acknowledgement.sequence + ) + } + } + + 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(isolation: isolated (any Actor)) { + _ = isolation + concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in + registration.results.value != nil + } + } + +#if DEBUG + package func concreteQueryRegistrationCountForTesting( + isolation: isolated (any Actor) = #isolation + ) async -> Int { + _ = isolation + return await queryIndex.queryRegistrationCountForTesting() + } +#endif + private func messageForResult( _ id: ConsoleMessage.ID, isolation: isolated (any Actor) 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/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 5f690304..7b5f6ad3 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -305,11 +305,17 @@ public final class WebInspectorFetchedResults private struct State { var fetchDescriptor: WebInspectorFetchDescriptor var sectionBy: WebInspectorSectionDescriptor? + var networkQuery: NetworkQuery? + var consoleQuery: ConsoleQuery? var items: [Model] var sections: [WebInspectorFetchSection] + var modelsByID: [Model.ID: Model] var snapshot: WebInspectorFetchedResultsSnapshot var revision: UInt64 var topologyRevision: UInt64 + var queryGeneration: UInt64? + var querySourceEpoch: UInt64? + var querySequence: UInt64 } private var state: State @@ -322,6 +328,8 @@ public final class WebInspectorFetchedResults @ObservationIgnored private var consoleQueryPlan: ConsoleMessageQueryPlan? @ObservationIgnored private var consoleQueryState: ConsoleMessageQueryState? @ObservationIgnored private var consoleIndexSequence: UInt64 + @ObservationIgnored private var queryRegistrationID: WebInspectorQueryRegistrationID? + @ObservationIgnored private var queryRegistrationLifetime: WebInspectorQueryRegistrationLifetime? /// The descriptor currently used by the results. public var fetchDescriptor: WebInspectorFetchDescriptor { @@ -367,11 +375,17 @@ public final class WebInspectorFetchedResults state = State( fetchDescriptor: fetchDescriptor, sectionBy: sectionBy, + networkQuery: nil, + consoleQuery: nil, items: items, sections: sections, + modelsByID: Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }), snapshot: WebInspectorFetchedResultsSnapshot(sections: sections), revision: 0, - topologyRevision: 0 + topologyRevision: 0, + queryGeneration: nil, + querySourceEpoch: nil, + querySequence: 0 ) self.modelContext = modelContext networkQueryPlan = nil @@ -380,12 +394,183 @@ public final class WebInspectorFetchedResults consoleQueryPlan = nil consoleQueryState = nil consoleIndexSequence = 0 + queryRegistrationID = nil + queryRegistrationLifetime = nil } deinit { updateBroker.finish() } + func installQueryRegistration( + id: WebInspectorQueryRegistrationID, + lifetime: WebInspectorQueryRegistrationLifetime + ) { + precondition( + queryRegistrationID == nil && queryRegistrationLifetime == nil, + "WebInspectorFetchedResults already owns a query registration." + ) + queryRegistrationID = id + queryRegistrationLifetime = lifetime + } + + var concreteQueryRegistrationID: WebInspectorQueryRegistrationID? { + queryRegistrationID + } + + func nextConcreteQueryGeneration() -> UInt64 { + guard let queryRegistrationLifetime else { + preconditionFailure("WebInspectorFetchedResults has no concrete query registration lifetime.") + } + return queryRegistrationLifetime.nextGeneration() + } + + func isCurrentConcreteQueryGeneration(_ generation: UInt64) -> Bool { + queryRegistrationLifetime?.isCurrent(generation: generation) == true + } + + private func installInitialConcreteState( + networkQuery: NetworkQuery? = nil, + consoleQuery: ConsoleQuery? = nil, + generation: UInt64, + projection: WebInspectorIndexedQueryProjection, + lookup: (Model.ID) -> Model? + ) { + precondition( + state.queryGeneration == nil, + "A concrete fetched-results initial state can only be installed once." + ) + precondition( + (networkQuery == nil) != (consoleQuery == nil), + "A concrete fetched-results state must own exactly one query kind." + ) + let resolved = resolve(projection.snapshot, reusing: [:], lookup: lookup) + state = State( + fetchDescriptor: state.fetchDescriptor, + sectionBy: nil, + networkQuery: networkQuery, + consoleQuery: consoleQuery, + items: resolved.items, + sections: resolved.sections, + modelsByID: resolved.modelsByID, + snapshot: projection.snapshot, + revision: 0, + topologyRevision: 0, + queryGeneration: generation, + querySourceEpoch: projection.sourceEpoch, + querySequence: projection.sequence + ) + } + + @discardableResult + private func applyConcreteProjection( + networkQuery: NetworkQuery? = nil, + consoleQuery: ConsoleQuery? = nil, + 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 else { + return false + } + guard generation >= currentGeneration else { + return false + } + if generation == currentGeneration { + guard projection.sourceEpoch >= currentSourceEpoch else { + return false + } + if projection.sourceEpoch == currentSourceEpoch, + projection.sequence <= state.querySequence { + return false + } + } else { + precondition( + isReplacement, + "A newer concrete query generation must publish as one replacement." + ) + } + + let resetsSource = projection.sourceEpoch != currentSourceEpoch + let shouldReset = isReplacement || resetsSource + let resolved = resolve( + projection.snapshot, + reusing: resetsSource ? [:] : state.modelsByID, + lookup: lookup + ) + 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, + networkQuery: networkQuery, + consoleQuery: consoleQuery, + queryGeneration: generation, + querySourceEpoch: projection.sourceEpoch, + querySequence: projection.sequence, + isReset: shouldReset, + updatedItemIDs: visibleReconfigureIDs + ) + return true + } + + 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 + } + 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 + } + 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 @@ -487,11 +672,24 @@ public final class WebInspectorFetchedResults return sequence } - /// Replaces the fetch descriptor and updates the result contents. + /// Replaces the fetch descriptor and updates descriptor-backed result contents. + /// + /// Results created by ``WebInspectorContext/networkRequests(matching:isolation:)`` + /// or ``WebInspectorContext/consoleMessages(matching:isolation:)`` have a + /// concrete query owner and must instead be updated with the matching + /// domain-specific `update(_:isolation:)` overload. + /// + /// - Precondition: This result was created by a `fetchedResults(for:)` + /// overload rather than a concrete domain query API. public func updateFetchDescriptor( _ descriptor: WebInspectorFetchDescriptor, isolation: isolated (any Actor) = #isolation ) { + guard state.queryGeneration == nil else { + preconditionFailure( + "A concrete query result must be updated with update(_:isolation:), not updateFetchDescriptor(_:isolation:)." + ) + } guard let modelContext else { preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") } @@ -501,8 +699,14 @@ public final class WebInspectorFetchedResults private func publish( items: [Model], sections: [WebInspectorFetchSection], + modelsByID: [Model.ID: Model]? = nil, snapshot: WebInspectorFetchedResultsSnapshot? = nil, fetchDescriptor: WebInspectorFetchDescriptor? = nil, + networkQuery: NetworkQuery? = nil, + consoleQuery: ConsoleQuery? = nil, + queryGeneration: UInt64? = nil, + querySourceEpoch: UInt64? = nil, + querySequence: UInt64? = nil, isReset: Bool = false, transaction suppliedTransaction: WebInspectorFetchedResultsTransaction? = nil, updatedItemIDs: Set = [] @@ -557,11 +761,17 @@ public final class WebInspectorFetchedResults state = State( fetchDescriptor: fetchDescriptor ?? oldState.fetchDescriptor, sectionBy: oldState.sectionBy, + networkQuery: networkQuery ?? oldState.networkQuery, + consoleQuery: consoleQuery ?? oldState.consoleQuery, items: items, sections: sections, + modelsByID: modelsByID ?? Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }), snapshot: newSnapshot, revision: revision, - topologyRevision: topologyRevision + topologyRevision: topologyRevision, + queryGeneration: queryGeneration ?? oldState.queryGeneration, + querySourceEpoch: querySourceEpoch ?? oldState.querySourceEpoch, + querySequence: querySequence ?? oldState.querySequence ) updateBroker.yield(.transaction( revision: revision, @@ -631,6 +841,56 @@ public final class WebInspectorFetchedResults } extension WebInspectorFetchedResults where Model == NetworkRequest { + func installInitialNetworkQuery( + _ query: NetworkQuery, + generation: UInt64, + projection: NetworkRequestIndex.QueryProjection, + lookup: (NetworkRequest.ID) -> NetworkRequest? + ) { + installInitialConcreteState( + networkQuery: query, + generation: generation, + projection: projection, + lookup: lookup + ) + } + + @discardableResult + func applyNetworkQueryProjection( + _ projection: NetworkRequestIndex.QueryProjection, + query: NetworkQuery, + generation: UInt64, + isReplacement: Bool, + lookup: (NetworkRequest.ID) -> NetworkRequest? + ) -> Bool { + applyConcreteProjection( + networkQuery: query, + generation: generation, + projection: projection, + isReplacement: isReplacement, + lookup: lookup + ) + } + + /// Replaces this result's concrete Network query atomically. + /// + /// - Precondition: This result was created by + /// ``WebInspectorContext/networkRequests(matching:isolation:)``. + public func update( + _ query: NetworkQuery, + isolation: isolated (any Actor) = #isolation + ) async throws { + guard queryRegistrationID != nil, state.queryGeneration != nil else { + preconditionFailure( + "A Network query can only update results created by networkRequests(matching:isolation:)." + ) + } + guard let modelContext else { + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") + } + try await modelContext.updateNetworkQuery(query, for: self, isolation: isolation) + } + var networkSnapshotForDelta: WebInspectorFetchedResultsSnapshot { state.snapshot } @@ -743,6 +1003,56 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { } extension WebInspectorFetchedResults where Model == ConsoleMessage { + func installInitialConsoleQuery( + _ query: ConsoleQuery, + generation: UInt64, + projection: ConsoleMessageIndex.QueryProjection, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) { + installInitialConcreteState( + consoleQuery: query, + generation: generation, + projection: projection, + lookup: lookup + ) + } + + @discardableResult + func applyConsoleQueryProjection( + _ projection: ConsoleMessageIndex.QueryProjection, + query: ConsoleQuery, + generation: UInt64, + isReplacement: Bool, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) -> Bool { + applyConcreteProjection( + consoleQuery: query, + generation: generation, + projection: projection, + isReplacement: isReplacement, + lookup: lookup + ) + } + + /// Replaces this result's concrete Console query atomically. + /// + /// - Precondition: This result was created by + /// ``WebInspectorContext/consoleMessages(matching:isolation:)``. + public func update( + _ query: ConsoleQuery, + isolation: isolated (any Actor) = #isolation + ) async throws { + guard queryRegistrationID != nil, state.queryGeneration != nil else { + preconditionFailure( + "A Console query can only update results created by consoleMessages(matching:isolation:)." + ) + } + guard let modelContext else { + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") + } + try await modelContext.updateConsoleQuery(query, for: self, isolation: isolation) + } + var consoleSnapshotForDelta: WebInspectorFetchedResultsSnapshot { state.snapshot } 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/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index 7793fd3b..02d1b026 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -8,33 +8,77 @@ package struct NetworkResultSetDelta: Sendable { } package actor NetworkRequestIndex { + package typealias QueryProjection = WebInspectorIndexedQueryProjection + package typealias QueryDelivery = WebInspectorIndexedQueryDelivery + private enum Mutation { - case replace([NetworkRequestRecordInput]) + 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) async { - await enqueue(.replace(inputs), sequence: sequence) + @discardableResult + package func replace( + with inputs: [NetworkRequestRecordInput], + sequence: UInt64, + sourceEpoch: UInt64? = nil + ) async -> [QueryDelivery] { + await enqueue(.replace(inputs, sourceEpoch: sourceEpoch), sequence: sequence) } - package func upsert(_ input: NetworkRequestRecordInput, sequence: UInt64) async { + @discardableResult + package func upsert( + _ input: NetworkRequestRecordInput, + sequence: UInt64 + ) async -> [QueryDelivery] { await enqueue(.upsert(input), sequence: sequence) } - private func enqueue(_ mutation: Mutation, sequence: UInt64) async { + private func enqueue(_ mutation: Mutation, sequence: UInt64) async -> [QueryDelivery] { precondition( sequence > lastAppliedSequence, "NetworkRequestIndex received an already-applied mutation sequence." @@ -43,7 +87,7 @@ package actor NetworkRequestIndex { pendingMutationsBySequence[sequence] == nil, "NetworkRequestIndex received a duplicate mutation sequence." ) - await withCheckedContinuation { continuation in + return await withCheckedContinuation { continuation in pendingMutationsBySequence[sequence] = PendingMutation( mutation: mutation, continuation: continuation @@ -56,21 +100,30 @@ package actor NetworkRequestIndex { while lastAppliedSequence < UInt64.max { let sequence = lastAppliedSequence + 1 guard let pending = pendingMutationsBySequence.removeValue(forKey: sequence) else { + resumeSequenceWaiters() return } - apply(pending.mutation, sequence: sequence) + let deliveries = apply(pending.mutation, sequence: sequence) lastAppliedSequence = sequence - pending.continuation.resume() + pending.continuation.resume(returning: deliveries) } precondition( pendingMutationsBySequence.isEmpty, "NetworkRequestIndex mutation sequence overflowed." ) + resumeSequenceWaiters() } - private func apply(_ mutation: Mutation, sequence: UInt64) { + private func apply(_ mutation: Mutation, sequence: UInt64) -> [QueryDelivery] { switch mutation { - case let .replace(inputs): + 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 = [] @@ -80,8 +133,14 @@ package actor NetworkRequestIndex { 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 + ) } } @@ -95,6 +154,409 @@ package actor NetworkRequestIndex { } } + package func register( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: NetworkQuery, + lifetime: WebInspectorQueryRegistrationLifetime, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + await waitUntilApplied(minimumSequence) + try Task.checkCancellation() + pruneQueryRegistrations() + guard lifetime.isCurrent(generation: generation) else { + throw CancellationError() + } + 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() + } + queryRegistrations[id] = QueryRegistration( + lifetime: WeakLifetime(lifetime), + active: version, + candidate: nil + ) + return projection(for: version) + } + + package func prepareReplacement( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: NetworkQuery, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + 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 { + guard lastAppliedSequence < minimumSequence else { + return + } + precondition( + nextSequenceWaiterID < UInt64.max, + "NetworkRequestIndex sequence waiter identity overflowed." + ) + let waiterID = nextSequenceWaiterID + nextSequenceWaiterID += 1 + await withCheckedContinuation { continuation in + sequenceWaiters[waiterID] = SequenceWaiter( + minimumSequence: minimumSequence, + continuation: continuation + ) + resumeSequenceWaiters() + } + } + + 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() + } + } + + 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 + } + + 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 + } + + 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 + ) + } + + 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: 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 ordersBefore(lhs, rhs, query: query) + } + return ids + } + + 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 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 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 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..?, @@ -103,7 +565,7 @@ package actor NetworkRequestIndex { ) -> NetworkResultSetDelta { precondition(plan.requiresModelPredicate == false) let indexSequence = lastAppliedSequence - let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) + let newSnapshot = legacySnapshot(plan: plan, sectionBy: sectionBy) let oldItemIDs = Set(oldSnapshot.itemIDs) let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence @@ -121,39 +583,28 @@ package actor NetworkRequestIndex { ) } -#if DEBUG - package func isMutationPendingForTesting(sequence: UInt64) -> Bool { - pendingMutationsBySequence[sequence] != nil - } -#endif - - private func snapshot( + private func legacySnapshot( plan: NetworkRequestQueryPlan, sectionBy: WebInspectorSectionDescriptor? ) -> WebInspectorFetchedResultsSnapshot { - let matchingRecords = visibleRecords(plan: plan) + let matchingRecords = legacyVisibleRecords(plan: plan) guard matchingRecords.isEmpty == false else { return WebInspectorFetchedResultsSnapshot() } guard let sectionBy else { return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) } - var sections: [( id: WebInspectorFetchSectionID, title: String?, itemIDs: [NetworkRequest.ID] )] = [] for record in matchingRecords { - let identity = sectionIdentity(for: record, sectionBy: sectionBy) + let identity = legacySectionIdentity(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] - )) + sections.append((identity.id, identity.title, [record.id])) } } return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in @@ -165,36 +616,19 @@ package actor NetworkRequestIndex { }) } - 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) + private func legacyVisibleRecords(plan: NetworkRequestQueryPlan) -> [NetworkRequestRecord] { + var records = orderedIDs.compactMap { recordsByID[$0] }.filter { + plan.matches(record: $0) == true } - if plan.sortComparators.isEmpty == false { - records.sort { lhs, rhs in - plan.ordersBefore(lhs, rhs) - } + records.sort { plan.ordersBefore($0, $1) } } - 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 - } + let upperBound = plan.fetchLimit.map { min(lowerBound + $0, records.count) } ?? records.count return Array(records[lowerBound.. ) -> (id: WebInspectorFetchSectionID, title: String?) { @@ -208,13 +642,9 @@ package actor NetworkRequestIndex { value = record.resourceCategory.rawValue case .networkMIMEType: value = record.mimeType - case .consoleSource, - .consoleLevel, - .consoleKind, - .consoleURL: + case .consoleSource, .consoleLevel, .consoleKind, .consoleURL: preconditionFailure("Console section descriptors cannot be applied to NetworkRequest results.") } - let title = value ?? "" return (WebInspectorFetchSectionID(rawValue: title), title) } diff --git a/Sources/WebInspectorDataKit/NetworkRequestStore.swift b/Sources/WebInspectorDataKit/NetworkRequestStore.swift index a3919265..94e2b901 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestStore.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestStore.swift @@ -7,6 +7,30 @@ import WebInspectorProxyKit /// store is confined by the actor supplied by each caller and never retains an /// actor token or starts transport work. package final class NetworkRequestStore { + 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 + } + #if DEBUG package struct PerformanceCounters: Equatable { package var fullModelProjectionCount = 0 @@ -26,6 +50,14 @@ package final class NetworkRequestStore { private var queryIndexSequence: UInt64 private var queryIndexNeedsRebuild: Bool private var fetchedResults: [WeakWebInspectorFetchedResults] + 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 @@ -40,6 +72,10 @@ package final class NetworkRequestStore { queryIndexSequence = 0 queryIndexNeedsRebuild = false fetchedResults = [] + querySourceEpoch = 0 + nextConcreteQueryRegistrationID = 0 + initializingConcreteQueries = [:] + concreteQueryRegistrations = [:] #if DEBUG performanceCounters = PerformanceCounters() #endif @@ -101,6 +137,170 @@ package final class NetworkRequestStore { fetchedResults.append(WeakWebInspectorFetchedResults(results)) } + package func results( + matching query: NetworkQuery, + modelContext: WebInspectorContext, + isolation: isolated (any Actor) = #isolation + ) async throws -> WebInspectorFetchedResults { + await syncQueryIndexIfNeeded(isolation: isolation) + let id = allocateConcreteQueryRegistrationID(isolation: isolation) + let lifetime = WebInspectorQueryRegistrationLifetime() + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + 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, isolation: isolation) } + ) + 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 func update( + _ query: NetworkQuery, + for results: WebInspectorFetchedResults, + isolation: isolated (any Actor) = #isolation + ) async throws { + guard let id = results.concreteQueryRegistrationID else { + preconditionFailure("Network fetched results are not registered in this store.") + } + await syncQueryIndexIfNeeded(isolation: isolation) + 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, isolation: isolation) } + ) + 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 updateFetchDescriptor( _ descriptor: WebInspectorFetchDescriptor, for results: WebInspectorFetchedResults, @@ -119,16 +319,30 @@ package final class NetworkRequestStore { /// Clears requests while remembering their protocol identities so late /// terminal events from the cleared loads remain ignorable. - package func clear(isolation: isolated (any Actor) = #isolation) { + package func clear(isolation: isolated (any Actor) = #isolation) async { clearedRequestIDs.formUnion(requestsByID.keys) - removeAllRequests(isolation: isolation) + let reset = prepareRemoveAllRequests(isolation: isolation) + await finishQueryIndexReset(reset, isolation: isolation) } - /// Resets state for a new attachment. Protocol identities may be reused by - /// the new target, so the late-event suppression set is also discarded. - package func resetForNewAttachment(isolation: isolated (any Actor) = #isolation) { + /// Begins a new attachment epoch and immediately removes prior identities. + package func prepareResetForNewAttachment( + isolation: isolated (any Actor) = #isolation + ) -> QueryIndexReset { clearedRequestIDs = [] - removeAllRequests(isolation: isolation) + return prepareRemoveAllRequests(isolation: isolation) + } + + package func finishQueryIndexReset( + _ reset: QueryIndexReset, + isolation: isolated (any Actor) = #isolation + ) async { + let deliveries = await queryIndex.replace( + with: [], + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + await applyConcreteDeliveries(deliveries, isolation: isolation) } @discardableResult @@ -580,16 +794,56 @@ package final class NetworkRequestStore { return request } - private func removeAllRequests(isolation: isolated (any Actor)) { - _ = isolation + private func prepareRemoveAllRequests( + isolation: isolated (any Actor) + ) -> QueryIndexReset { requestsByID = [:] orderedRequestIDs = [] orderIndicesByID = [:] - // The index is an actor. Mark it stale synchronously and replace it on - // the next async mutation instead of spawning unowned cleanup work. - queryIndexNeedsRebuild = true + queryIndexNeedsRebuild = false + advanceQuerySourceEpoch(isolation: isolation) + let sequence = nextQueryIndexSequence(isolation: isolation) collectionState.replaceCount(0) resetFetchedResults(isolation: isolation) + let reset = QueryIndexReset( + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + publishEmptyConcreteQueryResults(for: reset, isolation: isolation) + return reset + } + + private func publishEmptyConcreteQueryResults( + for reset: QueryIndexReset, + isolation: isolated (any Actor) + ) { + pruneConcreteQueryRegistrations(isolation: isolation) + 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(isolation: isolated (any Actor)) { + _ = isolation + precondition( + querySourceEpoch < UInt64.max, + "Network query source epoch overflowed." + ) + querySourceEpoch += 1 } private func currentRequests(isolation: isolated (any Actor)) -> [NetworkRequest] { @@ -660,7 +914,12 @@ package final class NetworkRequestStore { queryIndexNeedsRebuild = false let sequence = nextQueryIndexSequence(isolation: isolation) let inputs = currentRecordInputs(isolation: isolation) - await queryIndex.replace(with: inputs, sequence: sequence) + let deliveries = await queryIndex.replace( + with: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + await applyConcreteDeliveries(deliveries, isolation: isolation) } private func notifyRequestInserted( @@ -675,7 +934,8 @@ package final class NetworkRequestStore { } let sequence = nextQueryIndexSequence(isolation: isolation) let input = recordInput(for: request, isolation: isolation) - await queryIndex.upsert(input, sequence: sequence) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries, isolation: isolation) guard isCurrent(request, isolation: isolation) else { return } @@ -698,7 +958,8 @@ package final class NetworkRequestStore { } let sequence = nextQueryIndexSequence(isolation: isolation) let input = recordInput(for: request, isolation: isolation) - await queryIndex.upsert(input, sequence: sequence) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries, isolation: isolation) guard isCurrent(request, isolation: isolation) else { return } @@ -758,6 +1019,180 @@ package final class NetworkRequestStore { } } + private func allocateConcreteQueryRegistrationID( + isolation: isolated (any Actor) + ) -> WebInspectorQueryRegistrationID { + _ = isolation + precondition( + nextConcreteQueryRegistrationID < UInt64.max, + "Network concrete query registration identity overflowed." + ) + let id = WebInspectorQueryRegistrationID(rawValue: nextConcreteQueryRegistrationID) + nextConcreteQueryRegistrationID += 1 + return id + } + + private func applyConcreteDeliveries( + _ deliveries: [NetworkRequestIndex.QueryDelivery], + isolation: isolated (any Actor) + ) async { + pruneConcreteQueryRegistrations(isolation: isolation) + var acknowledgements: [( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + sourceEpoch: UInt64, + sequence: UInt64 + )] = [] + 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, isolation: isolation) } + ) + if applied { + acknowledgements.append(( + id, + delivery.generation, + delivery.projection.sourceEpoch, + 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 + } + for acknowledgement in acknowledgements { + await queryIndex.acknowledge( + id: acknowledgement.id, + generation: acknowledgement.generation, + sourceEpoch: acknowledgement.sourceEpoch, + sequence: acknowledgement.sequence + ) + } + } + + 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(isolation: isolated (any Actor)) { + _ = isolation + concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in + registration.results.value != nil + } + } + +#if DEBUG + package func concreteQueryRegistrationCountForTesting( + isolation: isolated (any Actor) = #isolation + ) async -> Int { + _ = isolation + return await queryIndex.queryRegistrationCountForTesting() + } +#endif + private func requestForResult( _ id: NetworkRequest.ID, isolation: isolated (any Actor) diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 37089705..f24609f3 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -239,9 +239,17 @@ public final class WebInspectorContext { state = .attaching notifyStatusChanged() teardownError = nil - resetNetworkModelsForNewAttachment(isolation: isolation) - startupTask = Task { [weak self, previousStartupTask, previousCurrentPageCleanupTask] in + let networkReset = networkRequests.prepareResetForNewAttachment(isolation: isolation) + let networkStore = networkRequests + startupTask = Task { [ + weak self, + networkStore, + networkReset, + previousStartupTask, + previousCurrentPageCleanupTask, + ] in _ = isolation + await networkStore.finishQueryIndexReset(networkReset, isolation: isolation) await previousStartupTask?.value await previousCurrentPageCleanupTask?.value guard Task.isCancelled == false else { @@ -299,9 +307,9 @@ public final class WebInspectorContext { } /// Clears retained Network requests and emits reset transactions. - public func clearNetworkRequests(isolation: isolated (any Actor) = #isolation) { + public func clearNetworkRequests(isolation: isolated (any Actor) = #isolation) async { requireOwner(isolation) - networkRequests.clear(isolation: isolation) + await networkRequests.clear(isolation: isolation) } /// Returns the registered Console message for an identifier. @@ -718,6 +726,64 @@ public final class WebInspectorContext { ) } + /// 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 func networkRequests( + matching query: NetworkQuery = NetworkQuery(), + isolation: isolated (any Actor) = #isolation + ) async throws -> WebInspectorFetchedResults { + requireOwner(isolation) + return try await networkRequests.results( + matching: query, + modelContext: self, + isolation: isolation + ) + } + + /// 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 func consoleMessages( + matching query: ConsoleQuery = ConsoleQuery(), + isolation: isolated (any Actor) = #isolation + ) async throws -> WebInspectorFetchedResults { + requireOwner(isolation) + return try await consoleMessages.results( + matching: query, + modelContext: self, + isolation: isolation + ) + } + + func updateNetworkQuery( + _ query: NetworkQuery, + for results: WebInspectorFetchedResults, + isolation: isolated (any Actor) = #isolation + ) async throws { + requireOwner(isolation) + guard results.modelContext === self else { + preconditionFailure("Network fetched results are not registered in this WebInspectorContext.") + } + try await networkRequests.update(query, for: results, isolation: isolation) + } + + func updateConsoleQuery( + _ query: ConsoleQuery, + for results: WebInspectorFetchedResults, + isolation: isolated (any Actor) = #isolation + ) async throws { + requireOwner(isolation) + guard results.modelContext === self else { + preconditionFailure("Console fetched results are not registered in this WebInspectorContext.") + } + try await consoleMessages.update(query, for: results, isolation: isolation) + } + /// Creates observable fetched results for a supported model type. public func fetchedResults( for descriptor: WebInspectorFetchDescriptor = .init(), @@ -1017,7 +1083,7 @@ public final class WebInspectorContext { currentPage = nil advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) - resetAttachmentBackedModels(isolation: isolation) + await resetAttachmentBackedModels(isolation: isolation) teardownError = nil teardownError = await disableEnabledDomains(isolation: isolation) transition(to: .detached) @@ -1045,7 +1111,7 @@ public final class WebInspectorContext { guard isCurrentPageGeneration(generation, isolation: isolation) else { return } - resetReplayBackedModelsBeforeEnable(isolation: isolation) + await resetReplayBackedModelsBeforeEnable(isolation: isolation) try await enableInspectorTracking(on: target, generation: generation, isolation: isolation) guard Task.isCancelled == false else { await disableEnabledDomainsAfterCancellation(isolation: isolation) @@ -1264,26 +1330,42 @@ public final class WebInspectorContext { private func resetReplayBackedModelsBeforeEnable( isolation: isolated (any Actor) - ) { + ) async { runtimeState.reset(isolation: isolation) - consoleMessages.resetForReplay(modelContext: self, isolation: isolation) + await consoleMessages.resetForReplay(modelContext: self, isolation: isolation) } - private func resetNetworkModelsForNewAttachment( + private func prepareCurrentPageStreamReset( isolation: isolated (any Actor) - ) { - networkRequests.resetForNewAttachment(isolation: isolation) + ) -> ConsoleMessageStore.QueryIndexReset { + runtimeState.reset(isolation: isolation) + let preparation = consoleMessages.prepareClearForLifecycle( + modelContext: self, + isolation: isolation + ) + applyConsoleMessageEffects(preparation.effects, isolation: isolation) + return preparation.queryIndexReset } - private func resetCurrentPageLifecycleModels(isolation: isolated (any Actor)) { + private func prepareCurrentPageLifecycleReset( + isolation: isolated (any Actor) + ) -> ConsoleMessageStore.QueryIndexReset { resetDOM(isolation: isolation) - runtimeState.reset(isolation: isolation) - clearConsoleMessages(isolation: isolation) + return prepareCurrentPageStreamReset(isolation: isolation) } - private func resetAttachmentBackedModels(isolation: isolated (any Actor)) { - resetCurrentPageLifecycleModels(isolation: isolation) - resetNetworkModelsForNewAttachment(isolation: isolation) + private func finishCurrentPageLifecycleReset( + _ reset: ConsoleMessageStore.QueryIndexReset, + isolation: isolated (any Actor) + ) async { + await consoleMessages.finishQueryIndexReset(reset, isolation: isolation) + } + + private func resetAttachmentBackedModels(isolation: isolated (any Actor)) async { + let consoleReset = prepareCurrentPageLifecycleReset(isolation: isolation) + let networkReset = networkRequests.prepareResetForNewAttachment(isolation: isolation) + await finishCurrentPageLifecycleReset(consoleReset, isolation: isolation) + await networkRequests.finishQueryIndexReset(networkReset, isolation: isolation) } private func disableEnabledDomains( @@ -1542,6 +1624,8 @@ public final class WebInspectorContext { case .failed, .detached: return case .attaching, .attached: + // A terminal failure preserves the last complete model snapshot for + // diagnosis. Explicit stop/detach owns semantic model clearing. transition(to: .failed(error)) } } @@ -1716,16 +1800,24 @@ extension WebInspectorContext { documentReloadTask = nil let generation = advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) - resetCurrentPageLifecycleModels(isolation: isolation) cancelConsoleObjectGroupReleaseTasks() for task in styleToggleTasks.values { task.cancel() } styleToggleTasks = [:] - currentPageRetargetTask = Task { [weak self, refreshedTarget, generation] in + let lifecycleReset = prepareCurrentPageLifecycleReset(isolation: isolation) + + currentPageRetargetTask = Task { [weak self, refreshedTarget, generation, lifecycleReset] in _ = isolation - await self?.retargetCurrentPage(refreshedTarget, generation: generation, isolation: isolation) + guard let self else { + return + } + await self.finishCurrentPageLifecycleReset(lifecycleReset, isolation: isolation) + guard self.isCurrentPageGeneration(generation, isolation: isolation) else { + return + } + await self.retargetCurrentPage(refreshedTarget, generation: generation, isolation: isolation) } } @@ -1852,10 +1944,19 @@ extension WebInspectorContext { cancelConsoleObjectGroupReleaseTasks() let generation = advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) + let streamReset = prepareCurrentPageStreamReset(isolation: isolation) - currentPageRetargetTask = Task { [weak self, generation] in + currentPageRetargetTask = Task { [weak self, generation, streamReset] in _ = isolation - await self?.retargetDestroyedCurrentPage(generation: generation, isolation: isolation) + guard let self else { + return + } + await self.finishCurrentPageLifecycleReset(streamReset, isolation: isolation) + guard Task.isCancelled == false, + self.isCurrentPageGeneration(generation, isolation: isolation) else { + return + } + await self.retargetDestroyedCurrentPage(generation: generation, isolation: isolation) } } @@ -1880,7 +1981,7 @@ extension WebInspectorContext { // page, so stop presenting the destroyed page's state and wait // for the next page target to appear. currentPage = nil - resetCurrentPageLifecycleModels(isolation: isolation) + resetDOM(isolation: isolation) if state == .attached { transition(to: .attaching) } @@ -1894,7 +1995,7 @@ extension WebInspectorContext { return } currentPage = replacement - resetCurrentPageLifecycleModels(isolation: isolation) + resetDOM(isolation: isolation) await retargetCurrentPage(replacement, generation: generation, isolation: isolation) } catch is CancellationError { return @@ -2593,14 +2694,6 @@ extension WebInspectorContext { } } - private func clearConsoleMessages(isolation: isolated (any Actor)) { - let effects = consoleMessages.clearForLifecycle( - modelContext: self, - isolation: isolation - ) - applyConsoleMessageEffects(effects, isolation: isolation) - } - private func applyConsoleMessageEffects( _ effects: ConsoleMessageStore.Effects, isolation: isolated (any Actor) diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md index ab0e6d8c..e07e2ed1 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md +++ b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md @@ -65,6 +65,30 @@ 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 @@ -83,6 +107,12 @@ access with no model layer. - ``NetworkRequest`` - ``ConsoleMessage`` +- ``NetworkQuery`` +- ``NetworkSort`` +- ``NetworkSection`` +- ``ConsoleQuery`` +- ``ConsoleSort`` +- ``ConsoleSection`` - ``WebInspectorFetchRequest`` - ``WebInspectorFetchedResults`` - ``WebInspectorFetchedResultsUpdate`` 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/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift b/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift index c7d3b801..d0dbd5a4 100644 --- a/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift +++ b/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift @@ -8,7 +8,9 @@ package struct NetworkListOverflowMenuView: View { package var body: some View { Button(role: .destructive) { - model.clearRequests() + Task { + await model.clearRequests() + } } label: { Label(String(localized: "network.controls.clear", bundle: WebInspectorUILocalization.bundle), systemImage: "trash") } diff --git a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift index f5f73515..223f8a97 100644 --- a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift +++ b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift @@ -114,9 +114,9 @@ package final class NetworkPanelModel { activeResourceFilters = [] } - package func clearRequests() { + package func clearRequests() async { selectedRequestID = nil - context.clearNetworkRequests() + await context.clearNetworkRequests() } package func fetchResponseBodyIfNeeded(for request: NetworkRequest) { diff --git a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift new file mode 100644 index 00000000..06a975cd --- /dev/null +++ b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift @@ -0,0 +1,307 @@ +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) +} + +@MainActor +@Test +func consoleConcreteQueryFiltersSortsSectionsAndWindowsCompactRecords() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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, isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + #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.") }, + isolation: MainActor.shared + ) + #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.") }, + isolation: MainActor.shared + ) + #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, + isolation: MainActor.shared + ) + #expect(results.items.map(\.id) == [logID]) + #expect(results.sections.map(\.id) == [.defaultSection]) +} + +@MainActor +@Test +func consoleConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = ConsoleMessageStore() + let recordCount = 10_000 + for ordinal in 0..? = try await store.results( + matching: ConsoleQuery(), + modelContext: context, + isolation: MainActor.shared + ) + weak let weakResults = results + + let registrationCount = await store.concreteQueryRegistrationCountForTesting( + isolation: MainActor.shared + ) + #expect(registrationCount == 1) + results = nil + #expect(weakResults == nil) + let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( + isolation: MainActor.shared + ) + #expect(prunedRegistrationCount == 0) +} + +@MainActor +private func makeIndexedConsoleMessage( + id: Int, + level: String, + text: String, + context: WebInspectorContext +) -> (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: WebInspectorContext +) 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.") }, + isolation: MainActor.shared + ) +} diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index 07b0b9f9..7d43e79f 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -165,7 +165,7 @@ func consoleMessageIndexDrainsMutationsInSequenceOrder() async throws { ConsoleMessageRecordInput(message: first, orderIndex: 0), sequence: 1 ) - await secondMutation.value + _ = await secondMutation.value let delta = await index.delta( plan: ConsoleMessageQueryPlan(descriptor: WebInspectorFetchDescriptor()), diff --git a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift new file mode 100644 index 00000000..116bb7e7 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift @@ -0,0 +1,625 @@ +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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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) +} + +@MainActor +@Test +func networkConcreteQueryReplacementAbsorbsMutationBetweenPrepareAndCommit() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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() + let deliveries = await index.upsert(second.input, sequence: 2) + + await #expect(throws: CancellationError.self) { + try await replacement.value + } + #expect(deliveries.map(\.generation) == [activeGeneration]) + #expect(deliveries.first?.projection.snapshot.itemIDs == [first.id, second.id]) + #expect(await index.commitReplacement( + id: registrationID, + generation: cancelledGeneration + ) == nil) +} + +@MainActor +@Test +func overlappingNetworkConcreteQueryReplacementsCommitOnlyTheNewestGeneration() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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() { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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 = context.registeredRequest(for: first.id), + let secondModel = context.registeredRequest(for: second.id) else { + Issue.record("Expected source-epoch fixtures to remain registered.") + return + } + let models = [first.id: firstModel, second.id: secondModel] + let results = WebInspectorFetchedResults( + fetchDescriptor: WebInspectorFetchDescriptor(), + 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 = WebInspectorContext.preview(isolation: MainActor.shared) + 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, + isolation: MainActor.shared + ) + let firstID = NetworkRequest.ID(firstProxyID) + let secondID = NetworkRequest.ID(secondProxyID) + let registeredFirst = store.request( + forProxyID: firstProxyID, + isolation: MainActor.shared + ) + 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, + isolation: MainActor.shared + ) + #expect(results.items.map(\.id) == [firstID]) + #expect(results.sections.map(\.id.rawValue) == ["PUT"]) + + await store.clear(isolation: MainActor.shared) + #expect(results.items.isEmpty) + #expect(results.sections.isEmpty) + #expect(results.snapshot.itemIDs.isEmpty) +} + +@MainActor +@Test +func networkConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { + let context = WebInspectorContext.preview(isolation: MainActor.shared) + let store = NetworkRequestStore() + let recordCount = 10_000 + for ordinal in 0..? = try await store.results( + matching: NetworkQuery(), + modelContext: context, + isolation: MainActor.shared + ) + weak let weakResults = results + + let registrationCount = await store.concreteQueryRegistrationCountForTesting( + isolation: MainActor.shared + ) + #expect(registrationCount == 1) + results = nil + #expect(weakResults == nil) + let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( + isolation: MainActor.shared + ) + #expect(prunedRegistrationCount == 0) +} + +@MainActor +private func makeIndexedNetworkRecord( + id: String, + url: String, + method: String, + timestamp: Double, + context: WebInspectorContext +) -> (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, + isolation: MainActor.shared + ) + guard let request = context.registeredRequest(for: modelID, isolation: MainActor.shared) 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: WebInspectorContext +) async { + await store.apply( + .requestWillBeSent( + id: id, + request: Network.Request(id: id, url: url, method: method), + resourceType: .fetch, + redirectResponse: nil, + timestamp: timestamp + ), + modelContext: context, + isolation: MainActor.shared + ) +} + +@MainActor +private func finishNetworkRequest( + _ id: Network.Request.ID, + timestamp: Double, + store: NetworkRequestStore, + context: WebInspectorContext +) async { + await store.apply( + .loadingFinished( + id: id, + timestamp: timestamp, + sourceMapURL: nil, + metrics: nil + ), + modelContext: context, + isolation: MainActor.shared + ) +} + +private 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 index 0d49ef73..ae20ba61 100644 --- a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift @@ -51,7 +51,7 @@ func networkRequestStoreOwnsIdentityOrderAndClearEpoch() async throws { #expect(first.statusCode == 200) #expect(store.collectionState.requestCount == 2) - store.clear(isolation: MainActor.shared) + await store.clear(isolation: MainActor.shared) let clearedFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) let clearedSecond = store.request(forProxyID: secondProxyID, isolation: MainActor.shared) diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 7fcf9b83..0c87b29b 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -1548,6 +1548,49 @@ func transportBackedStyleSheetTextEditRoutesToFrameTargetAndMarksUndo() async th try await editTask.value } +@MainActor +@Test +func currentPageCommitClearsOldLifecycleStateBeforeAcceptingNewPageEvents() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let (_, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let concreteResults = try await context.consoleMessages() + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "old page" + )), + targetID: .currentPage + ) + try await waitUntil { results.items.map(\.text) == ["old page"] } + #expect(concreteResults.items.map(\.text) == ["old page"]) + + context.apply(.didCommitProvisionalTarget(WebInspectorTargetCommitLifecycle( + oldTargetID: .currentPage, + newTarget: WebInspectorLifecycleTarget( + id: .currentPage, + kind: .page, + frameID: FrameID("committed-main-frame"), + isProvisional: false, + pageBindingID: "committed-page" + ) + ))) + + #expect(results.items.isEmpty) + #expect(concreteResults.items.isEmpty) + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "new page" + )), + targetID: .currentPage + ) + #expect(results.items.map(\.text) == ["new page"]) + #expect(concreteResults.items.map(\.text) == ["new page"]) +} + @MainActor @Test func currentPageCommitRetargetsDataKitStateToNewTransportTarget() async throws { @@ -1758,6 +1801,32 @@ func currentPageCommitRetargetsDataKitStateToNewTransportTarget() async throws { #expect(context.node(for: oldRouteChildID) == nil) } +@MainActor +@Test +func currentPageTargetDestroyedClearsStreamStateBeforeWaitingForReplacement() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let (_, context) = try await startContext(runtime: runtime) + let oldRoot = context.rootNode + let legacyResults: WebInspectorFetchedResults = context.fetchedResults() + let concreteResults = try await context.consoleMessages() + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "destroyed page" + )), + targetID: .currentPage + ) + #expect(legacyResults.items.map(\.text) == ["destroyed page"]) + #expect(concreteResults.items.map(\.text) == ["destroyed page"]) + + context.apply(.targetDestroyed(targetID: .currentPage)) + + #expect(context.rootNode === oldRoot) + #expect(legacyResults.items.isEmpty) + #expect(concreteResults.items.isEmpty) +} + @MainActor @Test func currentPageTargetDestroyedDuringRetargetDoesNotDetachOrClearNetwork() async throws { @@ -1894,6 +1963,8 @@ func startBeginsFreshNetworkAttachmentEpoch() async throws { try await waitUntil { networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] } + let concreteNetworkResults = try await context.networkRequests() + #expect(concreteNetworkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)]) await enqueueDomainDisableReplies(on: runtime.backend) await enqueueStartupReplies( @@ -1903,6 +1974,9 @@ func startBeginsFreshNetworkAttachmentEpoch() async throws { context.start() + #expect(networkResults.items.isEmpty) + #expect(concreteNetworkResults.items.isEmpty) + #expect(context.registeredRequest(for: NetworkRequest.ID(staleRequestID)) == nil) try await waitUntil { networkResults.items.isEmpty } try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("fresh-after-restart")) @@ -3100,7 +3174,7 @@ func networkRequestIndexDrainsDifferentIDMutationsInSequenceOrder() async throws NetworkRequestRecordInput(request: first, orderIndex: 0), sequence: 1 ) - await secondMutation.value + _ = await secondMutation.value let delta = await index.delta( plan: NetworkRequestQueryPlan( @@ -3148,7 +3222,7 @@ func networkRequestIndexBuffersUpsertUntilEarlierReplaceArrives() async throws { with: [NetworkRequestRecordInput(request: first, orderIndex: 0)], sequence: 1 ) - await upsert.value + _ = await upsert.value let delta = await index.delta( plan: NetworkRequestQueryPlan( @@ -3327,7 +3401,7 @@ func stalledFetchedResultsSubscriberDoesNotReconfigureRemovedItems() async throw timestamp: 2, in: context ) - context.clearNetworkRequests() + await context.clearNetworkRequests() let expectedRevision = try #require(results).revision results = nil @@ -3806,7 +3880,7 @@ func clearNetworkRequestsResetsDescriptorBackedQueryState() async throws { NetworkRequest.ID(Network.Request.ID("stale-b")), ]) - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(results.items.isEmpty) let freshID = Network.Request.ID("fresh-after-clear") @@ -4123,7 +4197,7 @@ func clearNetworkRequestsPublishesResetAndIgnoresClearedEvents() async throws { try await recorder.waitForTransactionCount(2) #expect(results.snapshot.itemIDs == [firstModelID, secondModelID]) - context.clearNetworkRequests() + await context.clearNetworkRequests() try await recorder.waitForTransactionCount(3) let reset = try #require(recorder.transactions.last) @@ -6669,7 +6743,7 @@ func fetchResponseBodyDropsCompletionAfterNetworkClear() async throws { await runtime.backend.recordedCommands().contains(RecordedCommand(domain: "Network", method: "getResponseBody")) } - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(context.registeredRequest(for: NetworkRequest.ID(requestID)) == nil) await runtime.backend.enqueue( Network.Body(data: "stale-body", base64Encoded: false), diff --git a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift index 941fc6f8..bded123d 100644 --- a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift +++ b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift @@ -550,7 +550,7 @@ struct NetworkDetailViewControllerTests { } #expect(didEnableMenu) - context.clearNetworkRequests() + await context.clearNetworkRequests() let didDisableMenu = await waitUntilRendered(in: viewController) { viewController.isDetailModeControlEnabledForTesting == false @@ -1743,8 +1743,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) @@ -2363,6 +2363,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..ce862c97 100644 --- a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift +++ b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift @@ -79,7 +79,7 @@ func clearAvailabilityUsesUnfilteredRequestsWhenFiltersHideEveryRequest() async #expect(model.displayRequestIDs.isEmpty) #expect(model.hasClearableRequests) - model.clearRequests() + await model.clearRequests() #expect(model.hasClearableRequests == false) #expect(await observedValues.waitUntilValue(false)) @@ -139,7 +139,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) @@ -660,7 +660,7 @@ func displayRequestsClearAfterReset() async throws { #expect(model.displayRequestIDs.count == 1) - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(model.displayRequestIDs.isEmpty) } @@ -746,7 +746,7 @@ func clearRequestsClearsSelectionButPreservesDisplayCriteria() async throws { model.setSearchText("cdn") model.setResourceFilter(.script, enabled: true) model.selectRequest(context.registeredRequest(for: requestID)) - model.clearRequests() + await model.clearRequests() #expect(model.selectedRequestID == nil) #expect(model.searchText == "cdn") From c05c060edf9be1acbe173585397cec316be18181 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:19:04 +0900 Subject: [PATCH 36/81] feat(proxy)!: enforce model command authority Gate model commands by exact feed generation and document epoch. Invalidate owned commands across retarget, refresh, close, and terminal paths. Make child handles weak references to the root proxy. BREAKING CHANGE: Proxy child handles no longer retain the root proxy, and active model feeds reject competing direct domain commands. --- Docs/WebInspectorKitsArchitecture.md | 46 +- .../LiveWebInspectorProxyBackend.swift | 3 +- .../StructuredEventScopes.swift | 31 +- .../Transport/TransportReplyStore.swift | 51 +- .../Transport/TransportSession.swift | 1040 +++++++++++++++-- .../Transport/TransportTypes.swift | 5 +- .../WebInspectorCommandAuthority.swift | 63 + .../WebInspectorDomainHandle.swift | 76 +- .../WebInspectorProxy.swift | 6 +- .../WebInspectorProxyBackend.swift | 5 +- .../WebInspectorTarget.swift | 55 +- .../ConnectionModelFeedTests.swift | 603 ++++++++++ .../TransportReplyStoreTests.swift | 20 +- .../TransportSessionTests.swift | 107 +- ...torProxyTransportCommandBackendTests.swift | 112 +- 15 files changed, 2054 insertions(+), 169 deletions(-) create mode 100644 Sources/WebInspectorProxyKit/WebInspectorCommandAuthority.swift diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index d66e72be..0fbc9519 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -398,10 +398,15 @@ promise. | 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. `WebInspectorProxy` and -`WebInspectorPage` are Sendable handles that strongly retain it; the core never -retains either handle. Keeping any handle keeps the connection usable without a -cycle. +`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 @@ -676,14 +681,39 @@ 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. Once a client -command owns a pending reply or a structured scope is admitted, a feed cannot -later reconstruct the missed prefix. Conversely, while a feed owns the -connection, public commands and structured scopes fail with +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. + 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. diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index a902a011..cada2c30 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -324,7 +324,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 ) } diff --git a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift index d761ed56..cd46d72b 100644 --- a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift +++ b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift @@ -246,16 +246,34 @@ public struct WebInspectorPage: Sendable { } } - package let proxy: WebInspectorProxy + package let proxyReference: WebInspectorProxyReference + package let authority: WebInspectorCommandAuthority package init(proxy: WebInspectorProxy) { - self.proxy = proxy + 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 { - try await proxy.pageGeneration() + 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 + } } } @@ -290,7 +308,12 @@ public struct WebInspectorPage: Sendable { } private var endpoint: DomainEndpoint { - DomainEndpoint(proxy: proxy, targetID: .currentPage, route: .currentPage) + DomainEndpoint( + proxyReference: proxyReference, + targetID: .currentPage, + route: .currentPage, + authority: authority + ) } } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index 2027e578..d0d463ec 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -3,7 +3,14 @@ import Foundation extension TransportSession { struct PendingReply: Sendable { enum Purpose: Equatable, Sendable { - case client + case direct( + bindingGeneration: WebInspectorPage.Generation?, + documentEpoch: ModelDocumentEpoch? + ) + case modelCommand( + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) case capability( key: ConnectionCapabilityKey, generation: WebInspectorPage.Generation, @@ -40,14 +47,39 @@ extension TransportSession { hasBufferedProvisionalResponse = false } - static func client( + static func direct( domain: ProtocolDomain, method: String, targetID: ProtocolTarget.ID?, - promise: ReplyPromise + promise: ReplyPromise, + bindingGeneration: WebInspectorPage.Generation?, + documentEpoch: ModelDocumentEpoch? ) -> PendingReply { PendingReply( - purpose: .client, + 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, @@ -213,6 +245,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 { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 1afb851e..740f59f2 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -13,7 +13,7 @@ private struct ConnectionOwnedCommandOperation: Sendable { let pendingReplyOwnership: ConnectionPendingReplyOwnership let timeoutAction: (@Sendable () async -> Void)? - func value() async throws { + func result() async throws -> ProtocolCommand.Result { try await withTaskCancellationHandler { try Task.checkCancellation() try await backend.sendJSONString(message) @@ -27,13 +27,97 @@ private struct ConnectionOwnedCommandOperation: Sendable { defer { timeoutTask?.cancel() } - _ = try await promise.value() + return try await promise.value() } onCancel: { Task { await promise.fulfill(.failure(CancellationError())) } } } + + func value() async throws { + _ = try await result() + } +} + +private struct ConnectionDirectCommandAdmission: Sendable { + let bindingGeneration: WebInspectorPage.Generation? + let documentEpoch: ModelDocumentEpoch? +} + +private struct ConnectionModelCommandOperation: Sendable { + let id: UInt64 + let task: Task +} + +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 struct ConnectionModelCommandTask: Sendable { + let task: Task + let authorization: ConnectionModelCommandAuthorization + let domain: ProtocolDomain + var pendingReplyOwnership: ConnectionPendingReplyOwnership? + var failureOverride: ConnectionModelCommandFailureOverride? +} + +private struct ConnectionModelCommandReadinessWaiter: Sendable { + let id: UInt64 + let operationID: UInt64 + let authorization: ConnectionModelCommandAuthorization + let domain: ProtocolDomain + let method: String + let routing: ProtocolCommand.Routing + let continuation: CheckedContinuation +} + +private enum ConnectionModelCommandReadiness: Sendable { + case ready(targetID: ProtocolTarget.ID?) + case waiting +} + +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 { @@ -113,14 +197,14 @@ 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 pendingRepliesToFail: [TransportSession.PendingReply] = [] - var missingTargetID: ProtocolTarget.ID? + var commandInvalidation = ConnectionCommandInvalidationEffects() } private struct MainPageTargetNotification: Sendable { @@ -128,6 +212,11 @@ private struct MainPageTargetNotification: Sendable { let result: TransportSession.MainPageTarget } +private struct EventEmissionEffects: Sendable { + var mainPageTargetNotification: MainPageTargetNotification? + var commandInvalidation = ConnectionCommandInvalidationEffects() +} + /// Owns one physical inspector connection. /// /// Target membership, command/reply routing, inbound ordering, and terminal @@ -184,6 +273,7 @@ package actor ConnectionCore { let releaseWaiters: [ReplyPromise] let capabilityTasks: [Task] let modelBootstrapTasks: [Task] + let modelCommandTasks: [Task] let closeAction: CloseAction func run() async { @@ -209,6 +299,9 @@ package actor ConnectionCore { for task in modelBootstrapTasks { await task.value } + for task in modelCommandTasks { + _ = await task.result + } await closeAction() } } @@ -235,6 +328,18 @@ package actor ConnectionCore { 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 nextModelCommandReadinessWaiterID: UInt64 + private var modelCommandReadinessWaiters: [UInt64: ConnectionModelCommandReadinessWaiter] + 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 @@ -286,6 +391,12 @@ package actor ConnectionCore { capabilityTasks = [:] nextModelBootstrapOperationID = 0 modelBootstrapTasks = [:] + nextModelCommandOperationID = 0 + modelCommandTasks = [:] + nextModelCommandReadinessWaiterID = 0 + modelCommandReadinessWaiters = [:] + modelCommandOwnerCountWaiters = [] + modelCommandReadinessCountWaiters = [] modelDocumentEpochs = [:] currentPageGeneration = WebInspectorPage.Generation(rawValue: 0) currentPageBindingGapIsOpen = false @@ -313,6 +424,19 @@ package actor ConnectionCore { eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) modelFeed?.mailbox.finish(throwing: WebInspectorProxyError.closed) modelFeed = nil + let readinessWaiters = modelCommandReadinessWaiters.values + modelCommandReadinessWaiters.removeAll() + for waiter in readinessWaiters { + waiter.continuation.resume(throwing: WebInspectorProxyError.closed) + } + let modelTasks = Array(modelCommandTasks.values) + modelCommandTasks.removeAll() + for modelTask in modelTasks { + modelTask.task.cancel() + if let ownership = modelTask.pendingReplyOwnership { + _ = replyStore.removePendingReply(ownership.key) + } + } terminalTask?.cancel() terminalTask = nil let tasks = Array(capabilityTasks.values) @@ -343,6 +467,14 @@ package actor ConnectionCore { 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 { @@ -515,6 +647,12 @@ package actor ConnectionCore { let completion = ReplyPromise() 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)? @@ -587,6 +725,12 @@ package actor ConnectionCore { } 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() { @@ -759,26 +903,577 @@ package actor ConnectionCore { throw terminalTransportError } - switch command.routing { + switch command.authority { + case .direct: + // 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) + } + case let .modelFeed(authorization): + let operation = try beginModelCommand(command, authorization: authorization) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await operation.task.value + } onCancel: { + Task { [weak self] in + await self?.cancelModelCommand(operation.id) + } + } + } + } + + 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) + ? modelDocumentEpoch(for: targetID) + : nil + ) + if let result = transportLocalResult(for: command, targetID: targetID) { + return result + } + return try await sendTarget(command, targetID: targetID, admission: admission) + } + + private func beginModelCommand( + _ command: ProtocolCommand, + authorization: ConnectionModelCommandAuthorization + ) throws -> ConnectionModelCommandOperation { + 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 task = Task { [weak self, command, authorization] () throws -> ProtocolCommand.Result in + guard let self else { + throw WebInspectorProxyError.closed + } + do { + let result = try await self.executeModelCommand( + command, + authorization: authorization, + operationID: operationID + ) + await self.finishModelCommand(operationID) + return result + } catch { + let resolvedError = await self.modelCommandFailure( + operationID, + fallback: error + ) + await self.finishModelCommand(operationID) + throw resolvedError + } + } + precondition( + modelCommandTasks[operationID] == nil, + "A model command operation identifier already has an owner." + ) + modelCommandTasks[operationID] = ConnectionModelCommandTask( + task: task, + authorization: authorization, + domain: command.domain, + pendingReplyOwnership: nil, + failureOverride: nil + ) + resumeModelCommandOwnerCountWaitersIfNeeded() + return ConnectionModelCommandOperation(id: operationID, task: task) + } + + private func executeModelCommand( + _ command: ProtocolCommand, + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) async throws -> ProtocolCommand.Result { + while true { + try Task.checkCancellation() + switch try modelCommandReadiness( + authorization: authorization, + domain: command.domain, + method: command.method, + routing: command.routing + ) { + case let .ready(targetID): + try Task.checkCancellation() + // There is intentionally no suspension between this final + // readiness check and pending-reply insertion. + let operation = try makeModelCommandOperation( + command, + targetID: targetID, + authorization: authorization, + operationID: operationID + ) + return try await operation.result() + case .waiting: + try await suspendModelCommandUntilReadinessChanges( + operationID: operationID, + authorization: authorization, + domain: command.domain, + method: command.method, + routing: command.routing + ) + } + } + } + + 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), 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: - return try await sendRoot(command) - case let .target(targetID): + 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) { + 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) { + 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 suspendModelCommandUntilReadinessChanges( + operationID: UInt64, + authorization: ConnectionModelCommandAuthorization, + domain: ProtocolDomain, + method: String, + routing: ProtocolCommand.Routing + ) async throws { + precondition( + nextModelCommandReadinessWaiterID < UInt64.max, + "A model command readiness waiter identifier exhausted UInt64." + ) + nextModelCommandReadinessWaiterID += 1 + let waiterID = nextModelCommandReadinessWaiterID + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { ( + continuation: CheckedContinuation + ) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + do { + switch try modelCommandReadiness( + authorization: authorization, + domain: domain, + method: method, + routing: routing + ) { + case .ready: + continuation.resume() + case .waiting: + modelCommandReadinessWaiters[waiterID] = ConnectionModelCommandReadinessWaiter( + id: waiterID, + operationID: operationID, + authorization: authorization, + domain: domain, + method: method, + routing: routing, + continuation: continuation + ) + resumeModelCommandOwnerCountWaitersIfNeeded() + } + } catch { + continuation.resume(throwing: error) + } + } + } onCancel: { + Task { [weak self] in + await self?.cancelModelCommandReadinessWaiter(waiterID) + } + } + } + + private func reevaluateModelCommandReadinessWaiters() { + for waiterID in modelCommandReadinessWaiters.keys.sorted() { + guard let waiter = modelCommandReadinessWaiters[waiterID] else { + continue + } + do { + guard case .ready = try modelCommandReadiness( + authorization: waiter.authorization, + domain: waiter.domain, + method: waiter.method, + routing: waiter.routing + ) else { + continue + } + modelCommandReadinessWaiters.removeValue(forKey: waiterID) + waiter.continuation.resume() + } catch { + modelCommandReadinessWaiters.removeValue(forKey: waiterID) + waiter.continuation.resume(throwing: error) + } + } + resumeModelCommandOwnerCountWaitersIfNeeded() + } + + private func cancelModelCommandReadinessWaiter(_ waiterID: UInt64) { + guard let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) else { + return + } + waiter.continuation.resume(throwing: CancellationError()) + 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 TransportSession.Error.missingTarget(targetID) + throw WebInspectorProxyError.staleIdentifier } - if let result = transportLocalResult(for: command, targetID: targetID) { - return result + 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 modelCommandFailure( + _ operationID: UInt64, + fallback: any Swift.Error + ) -> any Swift.Error { + modelCommandTasks[operationID]?.failureOverride?.error ?? fallback + } + + private func finishModelCommand(_ operationID: UInt64) { + guard let task = modelCommandTasks.removeValue(forKey: operationID) else { + return + } + 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." + ) + } + if let waiterID = modelCommandReadinessWaiters.first(where: { + $0.value.operationID == operationID + })?.key, + let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) { + waiter.continuation.resume(throwing: CancellationError()) + } + resumeModelCommandOwnerCountWaitersIfNeeded() + } + + private func cancelModelCommand(_ operationID: UInt64) async { + guard var commandTask = modelCommandTasks[operationID] else { + return + } + commandTask.failureOverride = .cancelled + modelCommandTasks[operationID] = commandTask + if let waiterID = modelCommandReadinessWaiters.first(where: { + $0.value.operationID == operationID + })?.key, + let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) { + waiter.continuation.resume(throwing: CancellationError()) + } + if let ownership = commandTask.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition(pending.purpose == ownership.purpose) + await pending.promise.fulfill(.failure(CancellationError())) + } + commandTask.task.cancel() + resumeModelCommandOwnerCountWaitersIfNeeded() + } + + 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) -> Bool { + 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 } - 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) + commandTask.failureOverride = failureOverride + modelCommandTasks[operationID] = commandTask + + let waiterIDs = modelCommandReadinessWaiters.compactMap { waiterID, waiter in + waiter.operationID == operationID ? waiterID : nil } - if let result = transportLocalResult(for: command, targetID: resolvedTarget) { - return result + for waiterID in waiterIDs { + guard let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) else { + continue + } + waiter.continuation.resume(throwing: failureOverride.error) } - return try await sendTarget(command, targetID: resolvedTarget) + + 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 + ) + ) + } + commandTask.task.cancel() + effects.modelCommandTasksToAwait.append(commandTask.task) + } + resumeModelCommandOwnerCountWaitersIfNeeded() + return effects + } + + private func completeCommandInvalidationEffects( + _ effects: ConnectionCommandInvalidationEffects + ) async { + for failure in effects.pendingFailures { + await failure.pending.promise.fulfill(.failure(failure.reason.error)) + } + for task in effects.modelCommandTasksToAwait { + _ = await task.result } } @@ -859,6 +1554,66 @@ package actor ConnectionCore { 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 modelCommandOwnerCountForTesting() -> Int { + modelCommandTasks.count + } + + package func modelCommandReadinessWaiterCountForTesting() -> Int { + modelCommandReadinessWaiters.count + } + + package func waitForModelCommandReadinessWaiterCountForTesting( + _ expectedCount: Int + ) async { + precondition(expectedCount >= 0) + guard modelCommandReadinessWaiters.count != 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 = modelCommandReadinessWaiters.count + 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 ) { @@ -1163,6 +1918,7 @@ package actor ConnectionCore { synchronization.didPublish = true registration.synchronization = synchronization modelFeed = registration + reevaluateModelCommandReadinessWaiters() return true } @@ -1260,6 +2016,7 @@ package actor ConnectionCore { if !domWasAlreadyComplete { _ = completeModelDomain(.dom, generation: generation) } + reevaluateModelCommandReadinessWaiters() return } @@ -1523,24 +2280,56 @@ package actor ConnectionCore { private func prepareDOMDocumentUpdateForModelFeed( _ event: ProtocolEvent - ) { - guard event.method == "DOM.documentUpdated", - var registration = modelFeed, - var bootstrap = registration.domBootstrap, - bootstrap.generation == currentPageGeneration else { - return + ) -> ConnectionCommandInvalidationEffects { + guard event.method == "DOM.documentUpdated" else { + return ConnectionCommandInvalidationEffects() } - let targetID = event.sourceTargetID - ?? event.targetID + let targetID = event.targetID + ?? event.sourceTargetID ?? targetRegistry.currentMainPageTargetID guard let targetID, - bootstrap.targetsByID[targetID] != nil else { - return + let target = targetRegistry.target(for: targetID), + targetRegistry.isCurrentPageModelTarget(target) else { + return ConnectionCommandInvalidationEffects() } + let oldEpoch = modelDocumentEpoch(for: targetID) _ = advanceModelDocumentEpoch(for: targetID) - bootstrap.needsCompletionMarker = true - registration.domBootstrap = bootstrap - modelFeed = registration + + 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), + let document = task.authorization.document else { + return false + } + return ProtocolTarget.ID(document.targetID.rawValue) == targetID + && document.epoch == oldEpoch + }, + failureOverride: .staleIdentifier, + pendingFailureReason: .staleIdentifier + ) + let directReplies = replyStore.removePendingReplies { pending in + guard pending.targetID == targetID, + isDocumentSensitive(pending.domain), + case let .direct(bindingGeneration, documentEpoch) = pending.purpose else { + return false + } + return bindingGeneration == currentPageGeneration + && documentEpoch == oldEpoch + } + effects.pendingFailures.append(contentsOf: directReplies.map { + ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) + }) + return effects } private func publishModelTargetLifecycleEvent( @@ -2229,7 +3018,10 @@ package actor ConnectionCore { } } - 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() try Task.checkCancellation() @@ -2238,11 +3030,12 @@ package actor ConnectionCore { method: command.method, parametersData: command.parametersData ) - let pending = try makeClientPendingReply( + let pending = makeDirectPendingReply( domain: command.domain, method: command.method, targetID: nil, - promise: promise + promise: promise, + admission: admission ) replyStore.insertRootReply(pending, commandID: commandID) do { @@ -2262,7 +3055,8 @@ package actor ConnectionCore { private func sendTarget( _ command: ProtocolCommand, - targetID: ProtocolTarget.ID + targetID: ProtocolTarget.ID, + admission: ConnectionDirectCommandAdmission ) async throws -> ProtocolCommand.Result { let innerCommandID = allocateCommandID() let outerCommandID = allocateCommandID() @@ -2279,11 +3073,12 @@ package actor ConnectionCore { targetIdentifier: targetID.rawValue, message: message ) - let pending = try makeClientPendingReply( + let pending = makeDirectPendingReply( domain: command.domain, method: command.method, targetID: targetID, - promise: promise + promise: promise, + admission: admission ) replyStore.insertTargetReply(pending, key: key, rootWrapperID: outerCommandID) do { @@ -2301,21 +3096,20 @@ package actor ConnectionCore { ) } - private func makeClientPendingReply( + private func makeDirectPendingReply( domain: ProtocolDomain, method: String, targetID: ProtocolTarget.ID?, - promise: ReplyPromise - ) throws -> TransportSession.PendingReply { - // This is the single ownership boundary for every wire command whose - // reply belongs to a direct client. Capability and future feed-owned - // commands use distinct purposes and do not pass through this factory. - try claimDirectConsumer() - return TransportSession.PendingReply.client( + promise: ReplyPromise, + admission: ConnectionDirectCommandAdmission + ) -> TransportSession.PendingReply { + TransportSession.PendingReply.direct( domain: domain, method: method, targetID: targetID, - promise: promise + promise: promise, + bindingGeneration: admission.bindingGeneration, + documentEpoch: admission.documentEpoch ) } @@ -2491,7 +3285,7 @@ package actor ConnectionCore { guard isOpen else { return } - let mainPageTargetNotification = emit( + let emission = emit( domain: domain, method: method, targetID: targetID, @@ -2500,10 +3294,7 @@ package actor ConnectionCore { destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget, reservedEventSequence: eventSequence ) - guard isOpen else { - return - } - await completeMainPageTargetNotification(mainPageTargetNotification) + await completeEventEmissionEffects(emission) guard isOpen else { return } @@ -2569,14 +3360,14 @@ package actor ConnectionCore { sourceTargetID: targetID, paramsData: parsed.paramsData ) - let mainPageTargetNotification = emit( + let emission = emit( domain: ProtocolDomain(method: method), method: method, targetID: emittedTargetID, sourceTargetID: targetID, paramsData: parsed.paramsData ) - await completeMainPageTargetNotification(mainPageTargetNotification) + await completeEventEmissionEffects(emission) } private func resolve( @@ -2626,8 +3417,20 @@ package actor ConnectionCore { key: TransportSession.PendingKey ) { switch pending.purpose { - case .client: + case .direct: break + 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): guard let ownership = capabilityTasks[operationID]?.pendingReplyOwnership else { preconditionFailure("A capability reply has no capability operation owner.") @@ -2664,8 +3467,8 @@ package actor ConnectionCore { for pending: TransportSession.PendingReply ) throws { switch pending.purpose { - case .client: - // Client replies have no internal publication side effect. + case .direct, .modelCommand: + // Consumer replies have no internal publication side effect. break case let .capability(key, generation, operationID): publishModelReplayCompletionIfNeeded( @@ -2949,11 +3752,8 @@ package actor ConnectionCore { ) } reconcileDOMBootstrapTargets() - guard isOpen else { - return RootEventMutation() - } return RootEventMutation( - pendingStyleSheetEvents: resolvePendingStyleSheets(for: resolution), + pendingStyleSheetEvents: isOpen ? resolvePendingStyleSheets(for: resolution) : [], bindingEffects: bindingEffects ) } @@ -2993,7 +3793,6 @@ package actor ConnectionCore { modelDocumentEpochs.removeValue(forKey: targetID) modelTargetMutationActionForTesting?() let capabilityWaiters = physicalTargetDidDisappear(targetID) - let pendingReplies = replyStore.removeTargetReplies(for: targetID) provisionalTargetMessageStore.removeTarget(targetID) styleSheetRouting.removeTarget(targetID) runtimeContextRegistry.removeTarget(targetID) @@ -3001,6 +3800,7 @@ package actor ConnectionCore { from: previousMainPageTargetID, to: targetRegistry.currentMainPageTargetID ) + let pendingReplies = replyStore.removeTargetReplies(for: targetID) if destroyedTargetWasCurrent, let destroyedRecord, let target = ModelTarget(record: destroyedRecord) { @@ -3013,8 +3813,14 @@ package actor ConnectionCore { return RootEventMutation( bindingEffects: bindingEffects, physicalTargetWaiters: capabilityWaiters, - pendingRepliesToFail: pendingReplies, - missingTargetID: targetID + commandInvalidation: ConnectionCommandInvalidationEffects( + pendingFailures: pendingReplies.map { + ConnectionPendingReplyFailure( + pending: $0, + reason: .missingTarget(targetID) + ) + } + ) ) } @@ -3027,12 +3833,12 @@ package actor ConnectionCore { let mutation = targetRegistry.commitTarget(oldTargetID: oldTargetID, newTargetID: newTargetID) modelTargetMutationActionForTesting?() var capabilityWaiters = PhysicalTargetDisappearanceWaiters() - var stalePendingReplies: [TransportSession.PendingReply] = [] + var disappearedTargetID: ProtocolTarget.ID? if mutation.shouldRetargetExternalState { let oldTargetID = mutation.committedOldTargetID capabilityWaiters = physicalTargetDidDisappear(oldTargetID) - stalePendingReplies = replyStore.removeTargetReplies(for: oldTargetID) + disappearedTargetID = oldTargetID provisionalTargetMessageStore.removeTarget(oldTargetID) styleSheetRouting.retarget(from: oldTargetID, to: newTargetID) runtimeContextRegistry.retarget(oldTargetID: oldTargetID, newTargetID: newTargetID) @@ -3042,6 +3848,9 @@ package actor ConnectionCore { 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) { @@ -3055,15 +3864,20 @@ package actor ConnectionCore { } modelDocumentEpochs.removeValue(forKey: oldTargetID) reconcileDOMBootstrapTargets() - guard isOpen else { - return RootEventMutation() - } return RootEventMutation( - pendingStyleSheetEvents: resolvePendingStyleSheets(for: mutation.resolvedFrameTarget), + pendingStyleSheetEvents: isOpen + ? resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) + : [], bindingEffects: bindingEffects, physicalTargetWaiters: capabilityWaiters, - pendingRepliesToFail: stalePendingReplies, - missingTargetID: mutation.committedOldTargetID + commandInvalidation: ConnectionCommandInvalidationEffects( + pendingFailures: remainingPendingReplies.map { + ConnectionPendingReplyFailure( + pending: $0, + reason: .missingTarget(mutation.committedOldTargetID) + ) + } + ) ) } @@ -3100,6 +3914,22 @@ package actor ConnectionCore { ) } + let oldGeneration = currentPageGeneration + var commandInvalidation = invalidateModelCommands( + where: { $0.authorization.generation == oldGeneration }, + failureOverride: .staleIdentifier, + pendingFailureReason: .staleIdentifier + ) + let directReplies = replyStore.removePendingReplies { pending in + guard case let .direct(bindingGeneration, _) = pending.purpose else { + return false + } + return bindingGeneration == oldGeneration + } + commandInvalidation.pendingFailures.append(contentsOf: directReplies.map { + ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) + }) + currentPageBindingGapIsOpen = oldTargetID != nil && newTargetID == nil currentPageGeneration = WebInspectorPage.Generation( rawValue: currentPageGeneration.rawValue &+ 1 @@ -3109,7 +3939,10 @@ package actor ConnectionCore { } publishModelBindingChange(hasCurrentBinding: newTargetID != nil) guard isOpen else { - return CurrentPageBindingChangeEffects() + return CurrentPageBindingChangeEffects( + modelBootstrapTasksToAwait: obsoleteBootstrapTasks, + commandInvalidation: commandInvalidation + ) } let keys = capabilities.states.keys.filter { $0.route == .currentPage } @@ -3127,13 +3960,15 @@ package actor ConnectionCore { return CurrentPageBindingChangeEffects( capabilityKeysToReconcile: newTargetID == nil ? [] : keys, releaseWaiters: releaseWaiters, - modelBootstrapTasksToAwait: obsoleteBootstrapTasks + modelBootstrapTasksToAwait: obsoleteBootstrapTasks, + commandInvalidation: commandInvalidation ) } private func completeCurrentPageBindingChange( _ effects: CurrentPageBindingChangeEffects ) async { + await completeCommandInvalidationEffects(effects.commandInvalidation) for task in effects.modelBootstrapTasksToAwait { await task.value } @@ -3170,17 +4005,7 @@ package actor ConnectionCore { await resumePhysicalTargetDisappearanceWaiters( mutation.physicalTargetWaiters ) - guard !mutation.pendingRepliesToFail.isEmpty else { - return - } - guard let missingTargetID = mutation.missingTargetID else { - preconditionFailure("Pending target replies lost their missing-target owner.") - } - for pending in mutation.pendingRepliesToFail { - await pending.promise.fulfill( - .failure(TransportSession.Error.missingTarget(missingTargetID)) - ) - } + await completeCommandInvalidationEffects(mutation.commandInvalidation) } private func publishModelBindingChange(hasCurrentBinding: Bool) { @@ -3407,13 +4232,13 @@ package actor ConnectionCore { guard isOpen else { return } - let mainPageTargetNotification = emit( + let emission = emit( domain: .css, method: "CSS.styleSheetAdded", targetID: event.targetID, paramsData: event.paramsData ) - await completeMainPageTargetNotification(mainPageTargetNotification) + await completeEventEmissionEffects(emission) guard isOpen else { return } @@ -3440,9 +4265,9 @@ package actor ConnectionCore { paramsData: Data, destroyedCurrentMainPageTarget: Bool = false, reservedEventSequence: TransportEventSequenceSnapshot? = nil - ) -> MainPageTargetNotification? { + ) -> EventEmissionEffects { guard isOpen else { - return nil + return EventEmissionEffects() } let eventSequence: TransportEventSequenceSnapshot if let reservedEventSequence { @@ -3472,7 +4297,7 @@ package actor ConnectionCore { // WebKit invalidates every bound node identifier before emitting // documentUpdated. Advance the target epoch before this event can be // projected into the ordered model feed. - prepareDOMDocumentUpdateForModelFeed(envelope) + let commandInvalidation = prepareDOMDocumentUpdateForModelFeed(envelope) if let eventDomain = webInspectorEventDomain(for: domain) { var terminalViolation: String? let targetSnapshot = snapshot() @@ -3548,7 +4373,7 @@ package actor ConnectionCore { } if let terminalViolation { handoffTermination(.protocolViolation(terminalViolation)) - return nil + return EventEmissionEffects(commandInvalidation: commandInvalidation) } } do { @@ -3560,11 +4385,11 @@ package actor ConnectionCore { handoffTermination( .protocolViolation("Failed to decode \(method): \(error)") ) - return nil + return EventEmissionEffects(commandInvalidation: commandInvalidation) } startNextDOMBootstrapIfNeeded() guard isOpen else { - return nil + return EventEmissionEffects(commandInvalidation: commandInvalidation) } for continuation in eventSubscribers.continuations(for: domain) { continuation.yield(envelope) @@ -3572,11 +4397,21 @@ package actor ConnectionCore { for continuation in eventSubscribers.orderedContinuations { continuation.yield(envelope) } - return prepareMainPageTargetNotificationIfNeeded( - receivedSequence: eventSequence.sequence + return EventEmissionEffects( + mainPageTargetNotification: prepareMainPageTargetNotificationIfNeeded( + receivedSequence: eventSequence.sequence + ), + commandInvalidation: commandInvalidation ) } + private func completeEventEmissionEffects( + _ effects: EventEmissionEffects + ) async { + await completeCommandInvalidationEffects(effects.commandInvalidation) + await completeMainPageTargetNotification(effects.mainPageTargetNotification) + } + private func publishConfiguredModelEvent( _ event: ProtocolEvent, in targetSnapshot: TransportSession.Snapshot @@ -3900,6 +4735,22 @@ package actor ConnectionCore { validateReplyOwnership(pending, key: key) } let pendingReplies = Array(pendingReplyRecords.values) + let readinessWaiters = modelCommandReadinessWaiters.values + modelCommandReadinessWaiters.removeAll() + for waiter in readinessWaiters { + waiter.continuation.resume(throwing: transportError) + } + resumeModelCommandOwnerCountWaitersIfNeeded() + var runningModelCommandTasks: [Task] = [] + for operationID in modelCommandTasks.keys.sorted() { + guard var commandTask = modelCommandTasks[operationID] else { + continue + } + commandTask.failureOverride = .terminal(transportError) + modelCommandTasks[operationID] = commandTask + commandTask.task.cancel() + runningModelCommandTasks.append(commandTask.task) + } let runningCapabilityTasks = capabilityTasks.values.map(\.task) capabilityTasks.removeAll() for task in runningCapabilityTasks { @@ -3932,6 +4783,7 @@ package actor ConnectionCore { releaseWaiters: releaseWaiters, capabilityTasks: runningCapabilityTasks, modelBootstrapTasks: runningModelBootstrapTasks, + modelCommandTasks: runningModelCommandTasks, closeAction: closeAction ) } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift b/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift index e225dac9..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 } } 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 index bdae676c..2865d6fc 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift @@ -1,14 +1,35 @@ import Foundation package struct DomainEndpoint: Sendable { - package let proxy: WebInspectorProxy + 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(proxy: WebInspectorProxy, targetID: WebInspectorTarget.ID, route: RoutingTargetID) { - self.proxy = proxy + 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( @@ -18,12 +39,16 @@ package struct DomainEndpoint: Sendable { 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 + payload: payload, + authority: authority ) } @@ -36,9 +61,15 @@ package struct DomainEndpoint: Sendable { AsyncThrowingStream, any Error> ) async throws -> Output ) async throws -> Output { - guard let backend = proxy.structuredEventBackend else { - throw unimplementedCommand(domain: domain.rawValue, method: "withEvents") - } + 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, @@ -52,23 +83,44 @@ package struct DomainEndpoint: Sendable { } package func domEvents() -> AsyncStream { - proxy.domEvents(targetID: targetID, route: route) + guard let proxy = proxyReference.resolve() else { + return finishedStream() + } + return proxy.domEvents(targetID: targetID, route: route) } package func cssEvents() -> AsyncStream { - proxy.cssEvents(targetID: targetID, route: route) + guard let proxy = proxyReference.resolve() else { + return finishedStream() + } + return proxy.cssEvents(targetID: targetID, route: route) } package func networkEvents() -> AsyncStream { - proxy.networkEvents(targetID: targetID, route: route) + guard let proxy = proxyReference.resolve() else { + return finishedStream() + } + return proxy.networkEvents(targetID: targetID, route: route) } package func consoleEvents() -> AsyncStream { - proxy.consoleEvents(targetID: targetID, route: route) + guard let proxy = proxyReference.resolve() else { + return finishedStream() + } + return proxy.consoleEvents(targetID: targetID, route: route) } package func runtimeEvents() -> AsyncStream { - proxy.runtimeEvents(targetID: targetID, route: route) + guard let proxy = proxyReference.resolve() else { + return finishedStream() + } + return proxy.runtimeEvents(targetID: targetID, route: route) + } +} + +private func finishedStream() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() } } diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index 41941cb9..c9754d7c 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -273,7 +273,8 @@ public actor WebInspectorProxy { route: RoutingTargetID, domain: WebInspectorProxyDomain, method: String, - payload: Payload + payload: Payload, + authority: WebInspectorCommandAuthority = .direct ) async throws -> Result { do { try await core.requireOpen() @@ -296,7 +297,8 @@ public actor WebInspectorProxy { route: commandTarget.route, domain: domain, method: method, - payload: payload + payload: payload, + authority: authority ) return try await backend.dispatchCommand(command) } diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift index 4515eadd..6dc8cfef 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift @@ -27,19 +27,22 @@ package struct WebInspectorProxyCommand: Se package let domain: WebInspectorProxyDomain package let method: String package let payload: Payload + package let authority: WebInspectorCommandAuthority package init( targetID: WebInspectorTarget.ID, route: RoutingTargetID, domain: WebInspectorProxyDomain, method: String, - payload: Payload + payload: Payload, + authority: WebInspectorCommandAuthority = .direct ) { self.targetID = targetID self.route = route self.domain = domain self.method = method self.payload = payload + self.authority = authority } } diff --git a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index 9378b6d6..a2568e81 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -82,10 +82,17 @@ public struct WebInspectorTarget: Identifiable, Sendable { /// navigation. public let isProvisional: Bool - package let proxy: WebInspectorProxy + package let proxyReference: WebInspectorProxyReference package let route: RoutingTargetID package let pageBindingID: String? + 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, kind: Kind, @@ -99,7 +106,25 @@ public struct WebInspectorTarget: Identifiable, Sendable { self.kind = kind self.frameID = frameID self.isProvisional = isProvisional - self.proxy = proxy + proxyReference = WebInspectorProxyReference(proxy) + self.route = route + self.pageBindingID = pageBindingID + } + + private init( + id: ID, + kind: Kind, + frameID: FrameID?, + isProvisional: Bool, + proxyReference: WebInspectorProxyReference, + route: RoutingTargetID, + pageBindingID: String? + ) { + self.id = id + self.kind = kind + self.frameID = frameID + self.isProvisional = isProvisional + self.proxyReference = proxyReference self.route = route self.pageBindingID = pageBindingID } @@ -110,7 +135,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { kind: lifecycleTarget.kind, frameID: lifecycleTarget.frameID, isProvisional: lifecycleTarget.isProvisional, - proxy: proxy, + proxyReference: proxyReference, route: route, pageBindingID: lifecycleTarget.pageBindingID ) @@ -151,21 +176,39 @@ public struct WebInspectorTarget: Identifiable, Sendable { } package var lifecycleEvents: AsyncStream { - proxy.targetLifecycleEvents(targetID: id, route: route) + guard let proxy = proxyReference.resolve() else { + return AsyncStream { continuation in + continuation.finish() + } + } + return proxy.targetLifecycleEvents(targetID: id, route: route) } package var targetedConsoleEvents: AsyncStream { - proxy.targetedConsoleEvents(targetID: id, route: route) + guard let proxy = proxyReference.resolve() else { + return AsyncStream { continuation in + continuation.finish() + } + } + return proxy.targetedConsoleEvents(targetID: id, route: route) } package func waitForModelEventSubscriptions() async { + guard let proxy = proxyReference.resolve() else { + return + } for domain in [WebInspectorProxyEventDomain.dom, .inspector, .css, .network, .console, .runtime] { await proxy.waitForEventSubscription(targetID: id, route: route, domain: domain) } } private var endpoint: DomainEndpoint { - DomainEndpoint(proxy: proxy, targetID: id, route: route) + DomainEndpoint( + proxyReference: proxyReference, + targetID: id, + route: route, + authority: .direct + ) } } diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 05e80b4a..83d5fd69 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -1762,6 +1762,17 @@ func domBootstrapCompletionOverflowTerminatesAtTheBoundary() async throws { let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") var iterator = feed.records.makeAsyncIterator() _ = try await modelFeedRequireReset(iterator.next()) + let waitingCommand = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) // targetSnapshot remains queued. bootstrapSnapshot fills the second slot, // so bootstrapComplete is the exact overflowing publication. @@ -1769,6 +1780,11 @@ func domBootstrapCompletionOverflowTerminatesAtTheBoundary() async throws { await #expect(throws: WebInspectorProxyError.self) { try await core.waitUntilClosed() } + await #expect(throws: TransportSession.Error.self) { + try await waitingCommand.value + } + #expect(await core.modelCommandOwnerCountForTesting() == 0) + #expect(await core.modelCommandReadinessWaiterCountForTesting() == 0) #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { try await iterator.next() @@ -2158,6 +2174,593 @@ func modelFeedCloseDisableRejectionPoisonsMailboxAndTerminatesConnection() async 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], + capacity: 16 + ) + } + 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: ["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: [], capacity: 8) + 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: [], capacity: 8) + 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: [], capacity: 16) + 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], capacity: 24) + 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: [], capacity: 8) + 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()) +} + +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] { diff --git a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift index 3912b604..2e8197ed 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift @@ -67,45 +67,47 @@ func replyStoreRemovingThenReusingCommandCleansStaleIndexes() { let newKey = TransportSession.ReplyKey(targetID: .init("frame-new"), commandID: 7) store.insertTargetReply( - clientPendingReply(targetID: .init("frame-old")), + directPendingReply(targetID: .init("frame-old")), key: oldKey, rootWrapperID: 100 ) let removed = store.removeTargetReply(for: oldKey) store.insertTargetReply( - clientPendingReply(targetID: .init("frame-new")), + directPendingReply(targetID: .init("frame-new")), key: newKey, rootWrapperID: 200 ) - #expect(removed?.purpose == .client) + #expect(removed?.purpose == .direct(bindingGeneration: nil, documentEpoch: nil)) #expect(store.pendingTargetReplyKeys == [newKey]) #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) #expect(store.takeTargetReplyKey(forRootWrapperID: 200) == newKey) } @Test -func replyStoreRootRemovalPreservesClientPurpose() { +func replyStoreRootRemovalPreservesDirectPurpose() { var store = TransportReplyStore() store.insertRootReply( - clientPendingReply(targetID: nil), + directPendingReply(targetID: nil), commandID: 42 ) let removed = store.removePendingReply(.root(42)) - #expect(removed?.purpose == .client) + #expect(removed?.purpose == .direct(bindingGeneration: nil, documentEpoch: nil)) #expect(store.pendingRootReplyIDs.isEmpty) } -private func clientPendingReply( +private func directPendingReply( targetID: ProtocolTarget.ID? ) -> TransportSession.PendingReply { - TransportSession.PendingReply.client( + TransportSession.PendingReply.direct( domain: .dom, method: "DOM.getDocument", targetID: targetID, - promise: ReplyPromise() + promise: ReplyPromise(), + bindingGeneration: nil, + documentEpoch: nil ) } diff --git a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift index f5c4ea62..e59d9cc0 100644 --- a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift @@ -609,7 +609,7 @@ func subframeCommitDoesNotConsumeCurrentMainPageTarget() async throws { } @Test -func targetCommitFailsPendingRepliesForRemovedOldTarget() 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":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) @@ -624,12 +624,111 @@ func targetCommitFailsPendingRepliesForRemovedOldTarget() async throws { await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) - await #expect(throws: TransportSession.Error.missingTarget(.init("page-main"))) { + 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 receiveTargetDispatch( + session, + targetID: .init("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 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() @@ -737,7 +836,7 @@ func provisionalTargetMessagesAreDispatchedAfterCommitTargetEvent() async throws } @Test -func removedTargetPendingReplyFailsAtCommitBeforeTimeout() async throws { +func oldBindingPendingReplyFailsAsStaleAtCommitBeforeTimeout() async throws { let backend = FakeTransportBackend() let responseTimeout = ManualResponseTimeout() let session = TransportSession( @@ -760,7 +859,7 @@ func removedTargetPendingReplyFailsAtCommitBeforeTimeout() async throws { await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) - await #expect(throws: TransportSession.Error.missingTarget(.init("page-main"))) { + await #expect(throws: WebInspectorProxyError.staleIdentifier) { try await sendTask.value } #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 49ddb1f5..74d04ed0 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let matchedStylesTask = Task { try await target.css.matchedStyles(for: DOM.Node.ID("42")) @@ -644,6 +651,44 @@ func droppingOpenProxyHandleReachesConnectionCoreDeinit() { #expect(weakProxy == nil) } +@Test +func pageTargetAndDomainHandlesDoNotOwnTheProxyLifecycle() async { + weak var weakProxy: WebInspectorProxy? + var page: WebInspectorPage? + var target: WebInspectorTarget? + var dom: DOM? + + do { + let proxy = WebInspectorProxy() + weakProxy = proxy + page = proxy.page + target = WebInspectorTarget( + id: WebInspectorTarget.ID("page-main"), + kind: .page, + frameID: FrameID("main-frame"), + isProvisional: false, + proxy: proxy, + route: .currentPage + ) + dom = target?.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 } + } + var iterator = dom?.events.makeAsyncIterator() + #expect(await iterator?.next() == nil) + var lifecycleIterator = target?.lifecycleEvents.makeAsyncIterator() + #expect(await lifecycleIterator?.next() == nil) +} + @Test func pendingCapabilitySendDoesNotRetainDroppedConnectionCore() async throws { let backend = SuspendedSendTransportBackend() @@ -975,7 +1020,8 @@ func transportBackendDecodesRootScopedDOMDocumentUpdatedForCurrentPage() async t 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let eventTask = Task { var iterator = target.dom.events.makeAsyncIterator() @@ -997,7 +1043,8 @@ func transportBackendDecodesDOMShadowAndPseudoEventsForTargetRoute() 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let eventTask = Task { var iterator = target.dom.events.makeAsyncIterator() @@ -1069,7 +1116,8 @@ 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 proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let eventTask = Task { var iterator = target.dom.events.makeAsyncIterator() @@ -1429,7 +1477,8 @@ func transportBackendDecodesNetworkResponseEventForTargetRoute() 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let eventTask = Task { var iterator = target.network.events.makeAsyncIterator() @@ -1463,7 +1512,8 @@ func transportBackendDecodesNetworkResponseEventWithoutType() 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let eventTask = Task { var iterator = target.network.events.makeAsyncIterator() @@ -1618,7 +1668,7 @@ func transportBackendEventSubscriptionWaitCompletesOnCancellation() async throws let target = try await proxy.waitForCurrentPage() let waitTask = Task { - await target.proxy.waitForEventSubscription( + await proxy.waitForEventSubscription( targetID: target.id, route: target.route, domain: .network @@ -1899,7 +1949,8 @@ func transportBackendDecodesWebSocketHandshakeEventsForTargetRoute() 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let requestEventTask = Task { var iterator = target.network.events.makeAsyncIterator() @@ -2010,7 +2061,8 @@ func transportBackendKeepsPeerSubscriberActiveWhenOneStreamTerminates() async th 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 proxy = WebInspectorProxy(backend: proxyBackend) + let page = pageTarget(proxy: proxy) let probe = EventDeliveryProbe() let firstSubscriber = Task { @@ -2108,7 +2160,8 @@ 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let evaluateTask = Task { try await target.runtime.evaluate("document.title", in: Runtime.ExecutionContext.ID("7")) @@ -2141,7 +2194,8 @@ func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries( 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 = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let target = pageTarget(proxy: proxy) let objectID = Runtime.RemoteObject.ID("object-1") let propertiesTask = Task { @@ -2218,13 +2272,13 @@ func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries( } @Test -func pendingRepliesClassifyClientAndCapabilityOwners() async throws { +func pendingRepliesClassifyDirectAndCapabilityOwners() 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 clientTask = Task { + let directTask = Task { try await proxy.page.page.reload() } let reload = try await waitForTargetMessage(backend, method: "Page.reload") @@ -2234,14 +2288,22 @@ func pendingRepliesClassifyClientAndCapabilityOwners() async throws { commandID: try messageID(reload.message) ) ) - #expect(await transport.pendingReplyPurposes() == [reloadPendingKey: .client]) + 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: "{}" ) - try await clientTask.value + try await directTask.value let bodyGate = CloseConnectionGate() let scopeTask = Task { @@ -4152,7 +4214,7 @@ private func requireCapabilityReplyPurpose( #expect(purposes.count == 1) let purpose = try #require(purposes[pendingKey]) switch purpose { - case .client: + case .direct, .modelCommand: Issue.record("Expected a capability reply purpose.") return 0 case .modelBootstrap: @@ -4186,7 +4248,11 @@ private func waitForEventSubscription( _ target: WebInspectorTarget, domain: WebInspectorProxyEventDomain ) async { - await target.proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: domain) + guard let proxy = target.proxyReference.resolve() else { + Issue.record("The test target lost its root proxy before subscription registration.") + return + } + await proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: domain) } private func receiveTargetEvent( From 0f5ed5b1e3394d1fc5bf92d1bf516efa20440551 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:14:19 +0900 Subject: [PATCH 37/81] refactor(ui): own Network query resource lifecycles --- Docs/WebInspectorKitsArchitecture.md | 40 ++- .../WebInspectorViewController.swift | 7 +- .../WebInspectorUI/NetworkTabController.swift | 39 ++- .../NetworkTabResourceViewController.swift | 126 +++++++ .../Tabs/PresentationContentStore.swift | 318 ++++++++++++++++-- .../WebInspectorUIBase/Localizable.xcstrings | 36 +- .../NetworkSplitViewController.swift | 42 ++- .../Detail/NetworkDetailViewController.swift | 56 +-- .../List/NetworkListOverflowMenuView.swift | 4 +- .../List/NetworkListViewController.swift | 16 +- .../NetworkPanelModel.swift | 259 +++++++++++--- .../NetworkPreviewFixtures.swift | 77 ++++- .../NetworkDetailViewControllerTests.swift | 114 ++++--- .../NetworkPanelModelTests.swift | 149 +++++++- .../ParentContainerTests.swift | 298 ++++++++++++++-- 15 files changed, 1319 insertions(+), 262 deletions(-) create mode 100644 Sources/WebInspectorUI/NetworkTabResourceViewController.swift diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 0fbc9519..cafe6bbb 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1608,18 +1608,34 @@ failure is logged at that UI lifecycle boundary rather than swallowed. Page-style observation is invalidated synchronously by the UI session's isolated deinitializer. -Because a tab factory may await an off-MainActor initial query, the root also -owns one content-loading state machine per tab: `idle`, `loading`, `ready`, or -`failed`. Synchronous UIKit selection/layout installs a loading placeholder; -one weak-root task invokes the `@MainActor async throws` factory. Concurrent -compact/regular requests for the same tab join that task and only one controller -is cached. A normal tab switch keeps the in-flight load/cache for reuse; -asynchronous descriptor removal or explicit root retirement/close cancels and -awaits the affected loads before clearing their cache entries. A factory failure -installs a retryable error controller rather than failing model attachment. The -task may retain the UI session, but never the root; isolated root -deinitialization synchronously cancels any remaining task as a backstop while -explicit retirement remains the awaited correctness path. +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. Only a root `clear()` or context-epoch transition +cancels and awaits the old load/model; a next generation waits for that +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: diff --git a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift index 0080eb89..85eef0d8 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift @@ -306,7 +306,12 @@ public final class WebInspectorViewController: UIViewController { guard presentationLifecycleCoordinator.isCurrentPresentation(generation) else { return } - presentationContentStore.clear() + 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 { diff --git a/Sources/WebInspectorUI/NetworkTabController.swift b/Sources/WebInspectorUI/NetworkTabController.swift index b6b07b00..fcb8cf5a 100644 --- a/Sources/WebInspectorUI/NetworkTabController.swift +++ b/Sources/WebInspectorUI/NetworkTabController.swift @@ -23,18 +23,37 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { contentStore: PresentationContentStore, layout: WebInspectorTab.HostLayout ) -> UIViewController { - let model = contentStore.networkPanelModel( - for: session.context, - contextEpoch: session.interface.contextBoundContentRevision - ) + let contextEpoch = session.interface.contextBoundContentRevision + return contentStore.networkViewController( + context: session.context, + contextEpoch: contextEpoch + ) { [weak contentStore] model in + guard let contentStore else { + preconditionFailure("A Network resource lost its presentation content store.") + } + return readyViewController( + layout: layout, + contentStore: contentStore, + contextEpoch: contextEpoch, + model: model + ) + } + } + + private func readyViewController( + layout: WebInspectorTab.HostLayout, + contentStore: PresentationContentStore, + contextEpoch: Int, + model: NetworkPanelModel + ) -> UIViewController { let listViewController = cachedListViewController( - session: session, contentStore: contentStore, + contextEpoch: contextEpoch, model: model ) let detailViewController = cachedDetailViewController( - session: session, contentStore: contentStore, + contextEpoch: contextEpoch, model: model ) @@ -57,26 +76,26 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { } private func cachedListViewController( - session: WebInspectorSession, contentStore: PresentationContentStore, + contextEpoch: Int, model: NetworkPanelModel ) -> NetworkListViewController { contentStore.viewController( for: contentKey(ContentID.list), - contextEpoch: session.interface.contextBoundContentRevision + contextEpoch: contextEpoch ) { NetworkListViewController(model: model) } } private func cachedDetailViewController( - session: WebInspectorSession, contentStore: PresentationContentStore, + contextEpoch: Int, model: NetworkPanelModel ) -> NetworkDetailViewController { contentStore.viewController( for: contentKey(ContentID.detail), - contextEpoch: session.interface.contextBoundContentRevision + contextEpoch: contextEpoch ) { NetworkDetailViewController( model: model, 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/Tabs/PresentationContentStore.swift b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift index ab219bea..16374115 100644 --- a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -1,20 +1,83 @@ #if canImport(UIKit) +import Observation import UIKit import WebInspectorDataKit import WebInspectorUINetwork -/// Owns view controllers and models whose lifetime is bounded by one root -/// inspector presentation. +/// Owns view controllers and asynchronous resources whose lifetime is bounded +/// by one root inspector presentation. @MainActor +@Observable package final class PresentationContentStore { - private let contentCache = WebInspectorTab.ContentCache() - private var networkPanelModel: NetworkPanelModel? - private var contextEpoch: Int? + package enum NetworkResourceStatus: Equatable, Sendable { + case idle + case loading + case ready + case failed(String) + } + + private enum NetworkResourceState { + case idle + case loading(contextEpoch: Int, generation: UInt64) + case ready(contextEpoch: Int, generation: UInt64, model: NetworkPanelModel) + case failed(contextEpoch: Int, generation: UInt64, message: String) + } - package init() {} + private final class WeakNetworkResourceViewController { + weak var value: NetworkTabResourceViewController? + + init(_ value: NetworkTabResourceViewController) { + self.value = value + } + } + + package typealias NetworkPanelModelFactory = @MainActor ( + _ context: WebInspectorContext + ) 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: WebInspectorContext? + private var networkResourceState: NetworkResourceState = .idle + package private(set) var contextEpoch: Int? + 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 { - clearResources() + networkResourceTask?.cancel() + networkRetirementTask?.cancel() + if case let .ready(_, _, model) = networkResourceState { + model.synchronouslyCancelForOwnerDeinit() + } + for resourceViewController in networkResourceViewControllers { + 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( @@ -26,40 +89,234 @@ package final class PresentationContentStore { return contentCache.viewController(for: key, make: make) } - package func networkPanelModel( - for context: WebInspectorContext, - contextEpoch: Int - ) -> NetworkPanelModel { + package func networkViewController( + context: WebInspectorContext, + contextEpoch: Int, + makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController + ) -> NetworkTabResourceViewController { prepare(for: contextEpoch) - if let networkPanelModel { + if let networkContext { precondition( - networkPanelModel.context === context, + networkContext === context, "A presentation context must change together with its context epoch." ) - return networkPanelModel + } 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)) - let model = NetworkPanelModel(context: context) - networkPanelModel = model - return model + switch networkResourceState { + case .idle: + startNetworkResource(context: context, contextEpoch: contextEpoch) + case let .loading(resourceEpoch, _), + let .ready(resourceEpoch, _, _), + let .failed(resourceEpoch, _, _): + precondition( + resourceEpoch == contextEpoch, + "A Network resource must change together with its presentation context epoch." + ) + } + renderNetworkResource(on: viewController) + return viewController } + /// Begins a context transition synchronously. Any next Network load waits + /// for the old load and model retirement before it can publish ready state. package func prepare(for contextEpoch: Int) { guard self.contextEpoch != contextEpoch else { return } - clearResources() + beginNetworkRetirement() + contentCache.removeAll() + networkResourceViewControllers.removeAll() + networkContext = nil self.contextEpoch = contextEpoch } - package func clear() { - clearResources() + /// Retires every presentation resource and waits for asynchronous owners. + package func clear() async { + beginNetworkRetirement() + contentCache.removeAll() + networkResourceViewControllers.removeAll() + networkContext = nil contextEpoch = nil + let retirementGeneration = networkRetirementGeneration + let retirementTask = networkRetirementTask + await retirementTask?.value + if networkRetirementGeneration == retirementGeneration { + networkRetirementTask = nil + } } - private func clearResources() { - networkPanelModel = nil - contentCache.removeAll() + private func startNetworkResource( + context: WebInspectorContext, + contextEpoch: Int + ) { + precondition( + self.contextEpoch == contextEpoch, + "A Network resource cannot start for an inactive presentation context epoch." + ) + guard case .idle = networkResourceState else { + return + } + + let generation = advanceNetworkResourceGeneration() + networkResourceState = .loading( + contextEpoch: contextEpoch, + generation: generation + ) + advanceNetworkResourceRevision() + renderNetworkResource() + + let retirementTask = networkRetirementTask + let makeNetworkPanelModel = makeNetworkPanelModel + networkResourceTask = Task { @MainActor [weak self] in + await retirementTask?.value + guard self?.isCurrentNetworkResource( + contextEpoch: contextEpoch, + generation: generation + ) == true else { + return + } + self?.networkRetirementTask = nil + + do { + let model = try await makeNetworkPanelModel(context) + guard let self, + isCurrentNetworkResource( + contextEpoch: contextEpoch, + generation: generation + ) else { + await model.retire() + return + } + networkResourceTask = nil + networkResourceState = .ready( + contextEpoch: contextEpoch, + generation: generation, + model: model + ) + advanceNetworkResourceRevision() + renderNetworkResource() + } catch { + guard let self, + isCurrentNetworkResource( + contextEpoch: contextEpoch, + generation: generation + ) else { + return + } + let message = error.localizedDescription + networkResourceTask = nil + networkResourceState = .failed( + contextEpoch: contextEpoch, + 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( + contextEpoch: Int, + generation: UInt64 + ) -> Bool { + guard self.contextEpoch == contextEpoch, + networkResourceGeneration == generation else { + return false + } + switch networkResourceState { + case let .loading(resourceEpoch, resourceGeneration): + return resourceEpoch == contextEpoch && 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 @@ -70,6 +327,21 @@ package final class PresentationContentStore { 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 + } #endif } #endif 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/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/NetworkListOverflowMenuView.swift b/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift index d0dbd5a4..c7d3b801 100644 --- a/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift +++ b/Sources/WebInspectorUINetwork/List/NetworkListOverflowMenuView.swift @@ -8,9 +8,7 @@ package struct NetworkListOverflowMenuView: View { package var body: some View { Button(role: .destructive) { - Task { - await model.clearRequests() - } + model.clearRequests() } label: { Label(String(localized: "network.controls.clear", bundle: WebInspectorUILocalization.bundle), systemImage: "trash") } diff --git a/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift b/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift index 209cfbda..7ee2d0c3 100644 --- a/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift +++ b/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift @@ -936,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 223f8a97..e2da205e 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 { @@ -27,28 +27,55 @@ private final class NetworkResponseBodyFetchCoordinator { @MainActor @Observable package final class NetworkPanelModel { + private enum Lifecycle { + case active + case retiring(Task?) + case retired + } + package let context: WebInspectorContext 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: WebInspectorContext, + requests: WebInspectorFetchedResults, + query: NetworkQuery + ) { self.context = context - self.requests = context.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: WebInspectorContext) 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 +94,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 @@ -82,18 +113,21 @@ package final class NetworkPanelModel { } 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 +139,182 @@ package final class NetworkPanelModel { return } activeResourceFilters = nextFilters + scheduleQueryUpdate() } package func clearResourceFilters() { + requireActive() guard activeResourceFilters.isEmpty == false else { return } activeResourceFilters = [] + scheduleQueryUpdate() } - package func clearRequests() async { + package func clearRequests() { + requireActive() selectedRequestID = nil - await context.clearNetworkRequests() + 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) { + requireActive() responseBodyFetchCoordinator.fetchIfNeeded(for: request) } - 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 +322,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..ec277844 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,6 +32,18 @@ package enum NetworkPreviewFixtures { return model } + #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) -> WebInspectorContext { let context = WebInspectorContext.preview(isolation: MainActor.shared) applySampleData(to: context, mode: mode) @@ -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/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift index bded123d..92a5e408 100644 --- a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift +++ b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift @@ -25,8 +25,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 +41,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 +58,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 +91,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 +106,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 +134,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 +152,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 +181,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 +224,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 +283,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 +332,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 +379,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 +431,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 +466,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 +507,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 +540,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) @@ -571,7 +572,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) @@ -600,7 +601,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) @@ -647,7 +648,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 +689,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 +719,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 +843,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 +899,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 +948,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 +999,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 +1044,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 +1099,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 +1156,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 +1203,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) @@ -1235,7 +1236,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) @@ -1267,7 +1268,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.fetchResponseBodyIfNeeded(for: request) let didFailInitialFetch = await waitForNetworkBodyPhase(in: request.responseBody) { phase in if case .failed = phase { @@ -1311,7 +1312,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 +1340,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 +1381,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 +1431,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 +1490,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 +1595,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 +1626,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 +1678,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 +1726,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( @@ -1763,7 +1764,7 @@ struct NetworkDetailViewControllerTests { 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 } @@ -1800,8 +1801,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 } @@ -1837,7 +1839,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 } @@ -1873,7 +1875,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 +1883,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 +1893,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 +1918,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() @@ -1986,7 +1994,7 @@ struct NetworkDetailViewControllerTests { @Test func listControllerDeallocatesWhileFetchedResultsUpdateTaskIsActive() async throws { - let model = NetworkPanelModel(context: makeContext()) + let model = try await NetworkPanelModel.make(context: makeContext()) let deinitProbe = UITestDeinitProbe() weak var weakViewController: NetworkListViewController? diff --git a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift index ce862c97..0d91e75b 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(context.registeredRequest(for: 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,12 +167,14 @@ 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) - await model.clearRequests() + model.clearRequests() + await model.waitForQueryUpdates() #expect(model.hasClearableRequests == false) #expect(await observedValues.waitUntilValue(false)) @@ -99,10 +194,11 @@ func selectedRequestUsesUnfilteredContextWhenFiltersHideRequest() async throws { timestamp: 1 ) let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) + 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) @@ -122,9 +218,10 @@ func selectedRequestInvalidatesWhenUnfilteredRequestDisappears() async throws { timestamp: 1 ) let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) + 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 } @@ -198,7 +295,7 @@ func requestDisplayUsesEncodingFallbackForURLDerivedLabelsAndFilters() async thr mimeType: nil, timestamp: 2 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let spacedURLRequest = try #require(context.registeredRequest(for: spacedURLRequestID)) let invalidEscapeRequest = try #require(context.registeredRequest(for: invalidEscapeRequestID)) @@ -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) @@ -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 model = try await NetworkPanelModel.make(context: context) let request = try #require(context.registeredRequest(for: 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,8 +763,9 @@ 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) @@ -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)) #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,12 +853,13 @@ 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)) - await model.clearRequests() + model.clearRequests() + await model.waitForQueryUpdates() #expect(model.selectedRequestID == nil) #expect(model.searchText == "cdn") @@ -768,7 +881,7 @@ func responseBodyFetchMovesUnavailablePreviewContextToFailedPhase() async throws timestamp: 1 ) let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) #expect(request.canFetchResponseBody) let expectedBody = request.responseBody diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index b13bf076..d295edbf 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -17,6 +17,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() @@ -292,6 +298,233 @@ struct ParentContainerTests { #expect(contentStore.contentCountForTesting == 1) } + @Test + func networkResourceTransitionsFromNativeLoadingToReadyInPlace() async throws { + let context = makeContext() + let factoryStarted = WebInspectorTestGate() + let factoryRelease = WebInspectorTestGate() + let contentStore = PresentationContentStore { context in + await factoryStarted.open() + await factoryRelease.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, + contextEpoch: 1 + ) { _ in + UIViewController() + } + + #expect(resourceViewController.phase == .loading) + #expect(resourceViewController.readyViewControllerForTesting == nil) + #expect(resourceViewController.contentUnavailableConfiguration != nil) + #expect(contentStore.networkResourceStatus == .loading) + + await factoryStarted.wait() + await factoryRelease.open() + await contentStore.waitForNetworkResourceTaskForTesting() + + #expect(await statuses.waitUntilValue(.ready)) + #expect(resourceViewController.phase == .ready) + #expect(resourceViewController.readyViewControllerForTesting != nil) + #expect(resourceViewController.contentUnavailableConfiguration == nil) + + await contentStore.clear() + } + + @Test + func networkResourceWrappersAreHostOwnedWhileModelRetirementIsRootOwned() async throws { + let context = makeContext() + let contentStore = PresentationContentStore() + let firstResourceViewController = contentStore.networkViewController( + context: context, + contextEpoch: 1 + ) { _ in + UIViewController() + } + await contentStore.waitForNetworkResourceTaskForTesting() + let model = try #require(contentStore.networkPanelModelForTesting) + + let secondResourceViewController = contentStore.networkViewController( + context: context, + contextEpoch: 1 + ) { _ 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 contextReplacementRejectsLateNetworkModelBeforePublishingNewReadyState() async throws { + let firstContext = makeContext() + let secondContext = makeContext() + let firstFactoryStarted = WebInspectorTestGate() + let firstFactoryRelease = WebInspectorTestGate() + let secondFactoryStarted = WebInspectorTestGate() + let secondFactoryRelease = WebInspectorTestGate() + var firstModel: NetworkPanelModel? + let contentStore = PresentationContentStore { context in + let model = try await NetworkPanelModel.make(context: context) + if context === firstContext { + firstModel = model + await firstFactoryStarted.open() + await firstFactoryRelease.wait() + } else { + #expect(context === secondContext) + await secondFactoryStarted.open() + await secondFactoryRelease.wait() + } + return model + } + + let firstResourceViewController = contentStore.networkViewController( + context: firstContext, + contextEpoch: 1 + ) { _ in + UIViewController() + } + await firstFactoryStarted.wait() + let withheldFirstModel = try #require(firstModel) + + let secondResourceViewController = contentStore.networkViewController( + context: secondContext, + contextEpoch: 2 + ) { _ in + UIViewController() + } + + #expect(firstResourceViewController.phase == .loading) + #expect(secondResourceViewController.phase == .loading) + #expect(contentStore.contextEpoch == 2) + + await firstFactoryRelease.open() + await secondFactoryStarted.wait() + + #expect(withheldFirstModel.isRetiredForTesting) + #expect(contentStore.networkResourceStatus == .loading) + #expect(secondResourceViewController.readyViewControllerForTesting == nil) + + await secondFactoryRelease.open() + await contentStore.waitForNetworkResourceTaskForTesting() + + let secondModel = try #require(contentStore.networkPanelModelForTesting) + #expect(secondModel.context === secondContext) + #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, + contextEpoch: 1 + ) { _ 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." + ) + + await contentStore.clear() + } + + @Test + func networkResourceLoadDoesNotRetainStore() async throws { + let context = makeContext() + let factoryStarted = WebInspectorTestGate() + let factoryRelease = WebInspectorTestGate() + var contentStore: PresentationContentStore? = PresentationContentStore { context in + let model = try await NetworkPanelModel.make(context: context) + await factoryStarted.open() + await factoryRelease.wait() + return model + } + weak var retainedStore = contentStore + let resourceViewController = try #require(contentStore).networkViewController( + context: context, + contextEpoch: 1 + ) { _ in + UIViewController() + } + await factoryStarted.wait() + + contentStore = nil + + #expect(retainedStore == nil) + #expect(resourceViewController.phase == .loading) + + await factoryRelease.open() + #expect(resourceViewController.readyViewControllerForTesting == nil) + } + + @Test + func networkResourceStoreDeinitSynchronouslyRetiresReadyBackstop() async throws { + let context = makeContext() + var contentStore: PresentationContentStore? = PresentationContentStore() + weak var retainedStore = contentStore + let resourceViewController = try #require(contentStore).networkViewController( + context: context, + contextEpoch: 1 + ) { _ in + UIViewController() + } + await contentStore?.waitForNetworkResourceTaskForTesting() + let model = try #require(contentStore?.networkPanelModelForTesting) + + #expect(resourceViewController.phase == .ready) + #expect(model.isRetiredForTesting == false) + + contentStore = nil + + #expect(retainedStore == nil) + #expect(model.isRetiredForTesting) + #expect(resourceViewController.phase == .loading) + #expect(resourceViewController.readyViewControllerForTesting == nil) + } + @Test func representationBeforeDeferredRetirementKeepsContentAndSkipsDetach() async throws { let session = makeSessionWithNoOpAttachment() @@ -418,13 +651,9 @@ struct ParentContainerTests { } @Test - func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() throws { + func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() async throws { let session = WebInspectorViewControllerPreviewFixtures.makeSession() - let viewController = WebInspectorViewController(session: session) - let model = viewController.presentationContentStoreForTesting.networkPanelModel( - for: session.context, - contextEpoch: session.interface.contextBoundContentRevision - ) + let model = try await NetworkPanelModel.make(context: session.context) #expect(session.context.rootNode?.nodeName == "#document") #expect(model.displayRequests.count >= 2) @@ -524,7 +753,6 @@ struct ParentContainerTests { weak var retainedCache: WebInspectorTab.ContentCache? weak var retainedSession: WebInspectorSession? weak var retainedContent: UIViewController? - weak var retainedNetworkModel: NetworkPanelModel? autoreleasepool { let customTab = WebInspectorTab( @@ -537,10 +765,6 @@ struct ParentContainerTests { let session = WebInspectorSession(tabs: [customTab]) let root = WebInspectorViewController(session: session) let contentStore = root.presentationContentStoreForTesting - let networkModel = contentStore.networkPanelModel( - for: session.context, - contextEpoch: session.interface.contextBoundContentRevision - ) let content = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, @@ -555,7 +779,6 @@ struct ParentContainerTests { retainedCache = contentStore.contentCacheForTesting retainedSession = session retainedContent = content - retainedNetworkModel = networkModel } #expect(retainedRoot == nil) @@ -563,7 +786,6 @@ struct ParentContainerTests { #expect(retainedCache == nil) #expect(retainedSession == nil) #expect(retainedContent == nil) - #expect(retainedNetworkModel == nil) } @Test @@ -943,7 +1165,7 @@ struct ParentContainerTests { } @Test - func compactFactoryUsesDomainNavigationControllers() throws { + func compactFactoryUsesDomainNavigationControllers() async throws { let session = WebInspectorSession(context: makeContext()) let contentStore = PresentationContentStore() @@ -965,13 +1187,19 @@ struct ParentContainerTests { let elementNavigationController = try #require(elementViewController as? DOMCompactNavigationController) #expect(elementNavigationController.viewControllers.first is DOMElementViewController) - let networkViewController = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - contentStore: contentStore, - 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) } @@ -1046,17 +1274,19 @@ struct ParentContainerTests { url: "https://example.com/app.js" ) let request = try #require(context.registeredRequest(for: requestID)) - let model = contentStore.networkPanelModel( - for: context, - contextEpoch: session.interface.contextBoundContentRevision - ) - let compactNavigationController = try #require( + 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 } @@ -1068,11 +1298,17 @@ struct ParentContainerTests { } #expect(didPushDetail) - let regularRoot = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - contentStore: contentStore, - 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( From 13b24dc5c7bf512587be8dce9a66360672668a4f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:28 +0900 Subject: [PATCH 38/81] fix(data): cancel concrete query sequence waiters --- .../ConsoleMessageIndex.swift | 39 ++++-- .../NetworkRequestIndex.swift | 39 ++++-- .../ConsoleConcreteQueryTests.swift | 112 ++++++++++++++++++ .../NetworkConcreteQueryTests.swift | 48 +++++++- 4 files changed, 212 insertions(+), 26 deletions(-) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift index 16b7806d..1a005311 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -25,7 +25,7 @@ package actor ConsoleMessageIndex { private struct SequenceWaiter { var minimumSequence: UInt64 - var continuation: CheckedContinuation + var continuation: CheckedContinuation } private final class WeakLifetime { @@ -163,7 +163,7 @@ package actor ConsoleMessageIndex { lifetime: WebInspectorQueryRegistrationLifetime, minimumSequence: UInt64 ) async throws -> QueryProjection { - await waitUntilApplied(minimumSequence) + try await waitUntilApplied(minimumSequence) try Task.checkCancellation() pruneQueryRegistrations() guard lifetime.isCurrent(generation: generation) else { @@ -196,7 +196,7 @@ package actor ConsoleMessageIndex { query: ConsoleQuery, minimumSequence: UInt64 ) async throws -> QueryProjection { - await waitUntilApplied(minimumSequence) + try await waitUntilApplied(minimumSequence) try Task.checkCancellation() pruneQueryRegistrations() guard var registration = queryRegistrations[id], @@ -283,7 +283,8 @@ package actor ConsoleMessageIndex { queryRegistrations[id] = registration } - private func waitUntilApplied(_ minimumSequence: UInt64) async { + private func waitUntilApplied(_ minimumSequence: UInt64) async throws { + try Task.checkCancellation() guard lastAppliedSequence < minimumSequence else { return } @@ -293,21 +294,37 @@ package actor ConsoleMessageIndex { ) let waiterID = nextSequenceWaiterID nextSequenceWaiterID += 1 - await withCheckedContinuation { continuation in - sequenceWaiters[waiterID] = SequenceWaiter( - minimumSequence: minimumSequence, - continuation: continuation - ) - resumeSequenceWaiters() + 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() + sequenceWaiters.removeValue(forKey: id)?.continuation.resume(returning: ()) } } diff --git a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index 02d1b026..c5e81ea6 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -23,7 +23,7 @@ package actor NetworkRequestIndex { private struct SequenceWaiter { var minimumSequence: UInt64 - var continuation: CheckedContinuation + var continuation: CheckedContinuation } private final class WeakLifetime { @@ -161,7 +161,7 @@ package actor NetworkRequestIndex { lifetime: WebInspectorQueryRegistrationLifetime, minimumSequence: UInt64 ) async throws -> QueryProjection { - await waitUntilApplied(minimumSequence) + try await waitUntilApplied(minimumSequence) try Task.checkCancellation() pruneQueryRegistrations() guard lifetime.isCurrent(generation: generation) else { @@ -194,7 +194,7 @@ package actor NetworkRequestIndex { query: NetworkQuery, minimumSequence: UInt64 ) async throws -> QueryProjection { - await waitUntilApplied(minimumSequence) + try await waitUntilApplied(minimumSequence) try Task.checkCancellation() pruneQueryRegistrations() guard var registration = queryRegistrations[id], @@ -281,7 +281,8 @@ package actor NetworkRequestIndex { queryRegistrations[id] = registration } - private func waitUntilApplied(_ minimumSequence: UInt64) async { + private func waitUntilApplied(_ minimumSequence: UInt64) async throws { + try Task.checkCancellation() guard lastAppliedSequence < minimumSequence else { return } @@ -291,21 +292,37 @@ package actor NetworkRequestIndex { ) let waiterID = nextSequenceWaiterID nextSequenceWaiterID += 1 - await withCheckedContinuation { continuation in - sequenceWaiters[waiterID] = SequenceWaiter( - minimumSequence: minimumSequence, - continuation: continuation - ) - resumeSequenceWaiters() + 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() + sequenceWaiters.removeValue(forKey: id)?.continuation.resume(returning: ()) } } diff --git a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift index 06a975cd..d9643140 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift @@ -14,6 +14,118 @@ func consoleQueryProvidesClosedDefaults() { #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 { diff --git a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift index 116bb7e7..eeccd9c3 100644 --- a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift +++ b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift @@ -118,6 +118,44 @@ func networkConcreteQueryRegistrationIncludesMutationAppliedWhileWaitingForIniti #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 { @@ -221,17 +259,19 @@ func cancelledNetworkConcreteQueryReplacementLeavesTheActiveGenerationWhole() as await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) } replacement.cancel() - let deliveries = await index.upsert(second.input, sequence: 2) await #expect(throws: CancellationError.self) { try await replacement.value } - #expect(deliveries.map(\.generation) == [activeGeneration]) - #expect(deliveries.first?.projection.snapshot.itemIDs == [first.id, second.id]) + #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 @@ -608,7 +648,7 @@ private func finishNetworkRequest( ) } -private func waitForConcreteQueryCondition( +func waitForConcreteQueryCondition( timeout: Duration = .seconds(2), _ condition: @escaping @Sendable () async -> Bool ) async throws { From 0d7b9cfd7d7968e915c37105e7f0abdd1947d941 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:04:09 +0900 Subject: [PATCH 39/81] docs(architecture): define final model ownership contracts --- Docs/WebInspectorKitsArchitecture.md | 281 ++++++++++++++++++++++----- 1 file changed, 229 insertions(+), 52 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index cafe6bbb..b73ce9b0 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -77,7 +77,8 @@ second production consumer of either core product. ### Non-goals - Expanding the supported Web Inspector protocol surface or exposing a raw - command escape hatch. + 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. @@ -145,7 +146,11 @@ External source evidence was read at fixed local revisions: `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. + 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. @@ -317,15 +322,21 @@ 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. ProxyKitTesting is -documented for DataKit consumers but requires those consumers to script six raw -startup replies and busy-poll private readiness. +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. A separate -DataKitTesting product provides model-level scenarios. +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 @@ -363,19 +374,19 @@ semantic model state in DataKit and UI-specific state in WebInspectorUI. | Variation | Absorbed by | Must not leak into | | --- | --- | --- | -| live native bridge versus scripted backend | internal connection-core backend boundary | domain handles and DataKit models | +| 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 and injected test clock/gates | public protocol DTOs | +| 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. Internal -initializers inject a backend and clock for tests. They are concrete package -boundaries, not public protocols with an unsupported external-conformance -promise. +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 @@ -391,7 +402,7 @@ promise. | 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 and query membership | package-internal `ConsoleMessageStore` plus its off-main-actor index | Console events and clear 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 | @@ -526,6 +537,11 @@ 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, @@ -579,6 +595,12 @@ package enum ConnectionModelFeedRecord: Sendable { sequence: UInt64, payload: ModelProtocolEvent ) + case domDocumentInvalidated( + generation: WebInspectorPage.Generation, + sequence: UInt64, + target: ModelTarget, + documentEpoch: ModelDocumentEpoch + ) case replayComplete( generation: WebInspectorPage.Generation, domain: ModelDomain, @@ -613,7 +635,8 @@ package struct ModelTargetSnapshot: Sendable { let targets: [ModelTarget] } -package enum ModelProtocolEvent: Sendable { /* physical target + typed payload */ } +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 */ } ``` @@ -714,6 +737,18 @@ 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. @@ -753,6 +788,25 @@ 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: @@ -765,6 +819,13 @@ 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: @@ -874,14 +935,30 @@ public enum WebInspectorProxyError: Error, Sendable { terminates only that subscriber with `eventBufferOverflow`; peer subscribers continue. - The package model feed is also bounded. Overflow currently terminates the feed - and connection. The later DataKit driver will map that terminal failure to a - failed model attachment; neither layer may fabricate a full domain - resynchronization. + and connection. The later DataKit driver maps that terminal failure to + `WebInspectorModelContext.Failure.feedBufferOverflow(capacity:)`; a mixed-feed + drop cannot truthfully attribute the failure to one domain. Neither layer may + fabricate 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 remains explicit opt-in, never the default. +The context reducer owns the overflow transition in every lifecycle phase, and +every overflow puts the context in +`.failed(.feedBufferOverflow(capacity:))`. During attach/synchronization, shared +transition waiters also throw that `Failure`. After attach has returned, state +observation reports the failure; attachment is not retroactively failed. Before +publishing `.failed`, the same reducer invalidates all model command authority, +makes connection-scoped identities/resources stale or terminal, and resets +every store. Existing DOM tree and fetched-results owners publish a full empty +reset through their current identity and remain registered for recovery. New +queries and all subsequent domain operations reject with the recorded `Failure` +while the context is failed; state observation and explicit attach/detach/close +remain available. An explicit attach from `.failed` tears down the poisoned +connection, starts a new attachment generation, and delivers the new +reset/snapshots through those existing result owners. + 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 @@ -934,11 +1011,17 @@ public final class WebInspectorModelContext { public struct Configuration: Sendable { public let domains: Set - public init(domains: Set = Set(Domain.allCases)) + public init( + domains: Set = [.dom, .network, .console, .runtime, .css] + ) } - public enum Domain: Hashable, Sendable, CaseIterable { - case 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 { @@ -951,7 +1034,7 @@ public final class WebInspectorModelContext { public enum Failure: Error, Equatable, Sendable { case connection(ConnectionFailure) case bootstrap(domain: Domain, message: String) - case eventBufferOverflow(domain: Domain) + case feedBufferOverflow(capacity: Int) } public enum TransitionError: Error, Equatable, Sendable { @@ -1090,6 +1173,15 @@ public final class WebInspectorModelContext { } ``` +`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 @@ -1148,7 +1240,8 @@ throwing getters follow the same rule; `nil` from a configured DOM getter means 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. +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: @@ -1161,7 +1254,7 @@ defines its identities: | 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 | +| 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 @@ -1532,6 +1625,13 @@ 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, @@ -1598,6 +1698,14 @@ 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 @@ -1621,9 +1729,11 @@ 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. Only a root `clear()` or context-epoch transition -cancels and awaits the old load/model; a next generation waits for that -retirement, and a late factory completion is retired before it can publish. +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 @@ -1653,8 +1763,13 @@ let tab = WebInspectorTab( 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 both sessions, controllers, and fetched-results owner -identities while resetting only models/resources whose owning epoch changed. +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 @@ -1708,11 +1823,15 @@ deinitialization. `isolated deinit` is used where static isolation owns synchronous state: -- `ConnectionCore` (a non-MainActor actor): cancel stored tasks, finish local - continuations/streams, and assert or complete synchronous terminal state. -- Other non-MainActor actor resources introduced by the final implementation, - including Network/Console query indexes and test runtime cores: synchronously - terminate their local waiters and bounded brokers. +- `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 @@ -1722,10 +1841,25 @@ 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 local -continuations synchronously. The higher platform floor enables isolated -deinitialization for the actual non-MainActor lifecycle actors instead of -forcing the entire observable graph onto MainActor. +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: @@ -1744,27 +1878,38 @@ 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. Backend callbacks also capture their receiver -weakly. Explicit close cancels and awaits tasks; dropping all public handles can -therefore reach the isolated deinitializer even when close was omitted. No -deinitializer is expected to break a cycle. +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, typed domain handles/DTOs, structured event scopes | `public` | direct ProxyKit consumer story | +| 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 | -| Scriptable Proxy test backend | `public` in ProxyKitTesting | direct consumer contract | +| 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: @@ -1798,6 +1943,8 @@ The migration removes rather than deprecates the following surfaces: 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. @@ -1889,9 +2036,18 @@ Tests are added before each owner is replaced. Required cases: `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. + 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 stream receiving N+1 pending events throws overflow for that - subscriber while a peer continues. + subscriber while a peer continues. Separately, mixed model-feed overflow + fails DataKit with `feedBufferOverflow(capacity:)` and does not fabricate a + domain attribution. Before attach completion the context enters `.failed` + and the waiter throws; after attach, the same state transition resets existing + results and rejects operations. Only an explicit re-attach starts a + recoverable new generation. 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. @@ -1905,8 +2061,10 @@ Tests are added before each owner is replaced. Required cases: 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. The isolated-deinit fallback synchronously detaches/restores/finishes exactly - once without launching async work. +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 @@ -1917,9 +2075,12 @@ Tests are added before each owner is replaced. Required cases: 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. + 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 @@ -1937,6 +2098,8 @@ Tests are added before each owner is replaced. Required cases: 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 @@ -1944,8 +2107,8 @@ Tests are added before each owner is replaced. Required cases: 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, descriptor removal, and root-close cancellation follow the root-owned - content state machine without a root/task cycle. + 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 @@ -1981,15 +2144,23 @@ Validation gates: - 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; - deallocation tests prove no owner-task cycle. + 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`, @@ -2008,10 +2179,16 @@ Validation gates: 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 From b86ca46a092d3f5eefb7b8bd3a1f16103217cb87 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:06:19 +0900 Subject: [PATCH 40/81] fix(ui): coalesce in-flight page highlights Keep one page-highlight command per active selection intent and use a generation token so stale task completion cannot clear its replacement. Add deterministic coalescing and retry coverage plus a cancellation-safe external test gate. --- .../Tree/DOMTreeTextView.swift | 23 ++- .../WebInspectorTestGate.swift | 128 +++++++++++++++ .../DOMTreeTextViewTests.swift | 146 ++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 Tests/WebInspectorTestSupport/WebInspectorTestGate.swift diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift index 624e4388..050222c3 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift @@ -69,6 +69,7 @@ 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 pageHighlightIntent: PageHighlightIntent? private var requestedChildNodeIDs: Set = [] private let findDecorationState = DOMTreeTextView.FindDecorationState() @@ -1251,7 +1252,8 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat private func reconcilePageSelectionHighlightIntentIfNeeded() { guard case .selection(let nodeID) = pageHighlightIntent, - currentTreeSnapshot.selectedNodeID == nodeID else { + currentTreeSnapshot.selectedNodeID == nodeID, + pageHighlightTask == nil else { return } highlightPageNode(nodeID, reason: .selection) @@ -1268,12 +1270,17 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat case .hover: cancelPageHighlightTask() } + let taskID = UUID() + pageHighlightTaskID = taskID 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 { @@ -1307,12 +1314,17 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat cancelPageHighlightTask() clearHoveredRow() pageHighlightIntent = .restoreSelectionAfterHover + let taskID = UUID() + pageHighlightTaskID = taskID 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 { self.pageHighlightIntent = nil @@ -1335,11 +1347,20 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat private func cancelPageHighlightTask(preservingIntent: Bool = false) { pageHighlightTask?.cancel() pageHighlightTask = nil + pageHighlightTaskID = nil if !preservingIntent { pageHighlightIntent = nil } } + private func finishPageHighlightTask(id: UUID) { + guard pageHighlightTaskID == id else { + return + } + pageHighlightTask = nil + pageHighlightTaskID = nil + } + private func presentDOMMenu(for nodeIDs: [DOMNode.ID], at location: CGPoint) { let menu = makeDOMMenu(for: nodeIDs) diff --git a/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift b/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift new file mode 100644 index 00000000..c1bac60f --- /dev/null +++ b/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift @@ -0,0 +1,128 @@ +import Synchronization + +/// A deterministic, cancellation-aware suspension point for asynchronous tests. +public final class WebInspectorTestGate: Sendable { + private final class Storage: Sendable { + struct State: Sendable { + var isOpen = false + var isCancelled = false + var waiters: [UInt64: CheckedContinuation] = [:] + var cancelledWaiterIDs: Set = [] + var nextWaiterID: UInt64 = 0 + } + + let state = Mutex(State()) + } + + private enum RegistrationAction { + case wait + case open + case cancelled + } + + private let storage = Storage() + + public init() {} + + deinit { + Self.cancel(storage) + } + + /// Opens the gate and resumes every current and future waiter successfully. + public func open() async { + let storage = storage + let waiters = storage.state.withLock { state -> [CheckedContinuation] in + guard !state.isOpen, !state.isCancelled else { + return [] + } + state.isOpen = true + state.cancelledWaiterIDs.removeAll(keepingCapacity: false) + 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() async { + Self.cancel(storage) + } + + /// Suspends until the gate opens or is cancelled. + /// + /// Cancellation is intentionally terminal for a test gate. Callers that + /// need to observe cancellation should use the module-internal throwing + /// operation used by the raw-wire driver. + public func wait() async { + _ = try? await Self.waitUntilOpen(storage) + } + + func waitUntilOpen() async throws { + try await Self.waitUntilOpen(storage) + } + + private static func waitUntilOpen(_ storage: Storage) 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 + return state.nextWaiterID + } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let action = storage.state.withLock { state -> RegistrationAction in + if state.cancelledWaiterIDs.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 let waiter = state.waiters.removeValue(forKey: waiterID) else { + if !state.isOpen, !state.isCancelled { + state.cancelledWaiterIDs.insert(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.cancelledWaiterIDs.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/DOMTreeTextViewTests.swift b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift index 8431a5b9..e3acb7be 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,68 @@ 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 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 +1218,89 @@ private final class NodeActionRecorder { } } +@MainActor +private final class ControlledNodeActionRecorder { + enum Resolution { + case success + case failure + } + + private struct IntentionalFailure: Error {} + + private var nodeIDs: [DOMNode.ID] = [] + private var owners: [DOMTreePageHighlightOwner] = [] + private var gates: [WebInspectorTestGate] = [] + private var failedInvocationIndexes: Set = [] + private var invocationWaiters: [( + count: Int, + continuation: CheckedContinuation + )] = [] + + func run(_ nodeID: DOMNode.ID, owner: DOMTreePageHighlightOwner) async throws { + let invocationIndex = nodeIDs.count + let gate = WebInspectorTestGate() + nodeIDs.append(nodeID) + owners.append(owner) + gates.append(gate) + resumeInvocationWaitersIfNeeded() + + await gate.wait() + try Task.checkCancellation() + 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) + } + await 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 VoidActionRecorder { private(set) var recordCount = 0 From 2ba7147108aaaa29364d76d13604b8eda61af4b2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:32:36 +0900 Subject: [PATCH 41/81] fix(proxy): synchronize reply promise lifecycle --- .../Transport/ReplyPromise.swift | 137 +++++++++-- .../Transport/TransportSession.swift | 133 +++++------ .../ConnectionModelFeedTests.swift | 1 + .../ReplyPromiseTests.swift | 226 ++++++++++++++++++ 4 files changed, 407 insertions(+), 90 deletions(-) create mode 100644 Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift 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/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 740f59f2..e4c4e0a3 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -29,9 +29,7 @@ private struct ConnectionOwnedCommandOperation: Sendable { } return try await promise.value() } onCancel: { - Task { - await promise.fulfill(.failure(CancellationError())) - } + promise.fulfill(.failure(CancellationError())) } } @@ -278,19 +276,19 @@ package actor ConnectionCore { func run() async { for pending in pendingReplies { - await pending.promise.fulfill(.failure(transportError)) + pending.promise.fulfill(.failure(transportError)) } for waiter in mainPageTargetWaiters { - await waiter.fulfill(.failure(transportError)) + waiter.fulfill(.failure(transportError)) } for waiter in activationWaiters { - await waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) + waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) } for waiter in releaseWaiters { if let scopeError { - await waiter.fulfill(.failure(scopeError)) + waiter.fulfill(.failure(scopeError)) } else { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } } for task in capabilityTasks { @@ -639,7 +637,7 @@ package actor ConnectionCore { case .rollingBack: preconditionFailure("A model feed cannot close while its failed open is rolling back.") case .closing(let completion): - return try await completion.value() + return try await completion.valueIgnoringCancellation() case .active: break } @@ -669,17 +667,17 @@ package actor ConnectionCore { if let cleanupError { await terminateForModelFeedCapabilityCleanupFailure(cleanupError) - await completion.fulfill(.failure(cleanupError)) + completion.fulfill(.failure(cleanupError)) throw cleanupError } guard isOpen else { do { try await waitUntilClosed() - await completion.fulfill(.success(())) + completion.fulfill(.success(())) return } catch { - await completion.fulfill(.failure(error)) + completion.fulfill(.failure(error)) throw error } } @@ -689,7 +687,7 @@ package actor ConnectionCore { } modelFeed = nil currentRegistration.mailbox.finish() - await completion.fulfill(.success(())) + completion.fulfill(.success(())) } private func appendModelFeedCapabilityLease( @@ -1365,7 +1363,7 @@ package actor ConnectionCore { if let ownership = commandTask.pendingReplyOwnership, let pending = replyStore.removePendingReply(ownership.key) { precondition(pending.purpose == ownership.purpose) - await pending.promise.fulfill(.failure(CancellationError())) + pending.promise.fulfill(.failure(CancellationError())) } commandTask.task.cancel() resumeModelCommandOwnerCountWaitersIfNeeded() @@ -1470,7 +1468,7 @@ package actor ConnectionCore { _ effects: ConnectionCommandInvalidationEffects ) async { for failure in effects.pendingFailures { - await failure.pending.promise.fulfill(.failure(failure.reason.error)) + failure.pending.promise.fulfill(.failure(failure.reason.error)) } for task in effects.modelCommandTasksToAwait { _ = await task.result @@ -1662,7 +1660,7 @@ package actor ConnectionCore { } catch { return } - await self.failMainPageTargetWaiter(waiter.id, error: TransportSession.Error.missingMainPageTarget) + self.failMainPageTargetWaiter(waiter.id, error: TransportSession.Error.missingMainPageTarget) } } defer { @@ -1670,13 +1668,7 @@ package actor ConnectionCore { } do { - return try await withTaskCancellationHandler { - try await waiter.promise.value() - } onCancel: { - Task { - await self.failMainPageTargetWaiter(waiter.id, error: CancellationError()) - } - } + return try await waiter.promise.value() } catch { mainPageTargetWaiterStore.remove(id: waiter.id) throw error @@ -2385,14 +2377,15 @@ package actor ConnectionCore { for key: ConnectionCapabilityKey, activation: ReplyPromise? ) async throws { - try await withTaskCancellationHandler { + do { await reconcileCapability(for: key) try Task.checkCancellation() try await activation?.value() - } onCancel: { [weak self] in - Task { - await self?.cancelCapabilityActivation(leaseOwner, for: key) + } catch { + if error is CancellationError { + await cancelCapabilityActivation(leaseOwner, for: key) } + throw error } } @@ -2411,7 +2404,7 @@ package actor ConnectionCore { eventScopeActivationCancellationAction = nil await cancellationAction?() } - await waiter.fulfill(.failure(CancellationError())) + waiter.fulfill(.failure(CancellationError())) } private func releaseCapabilityLease( @@ -2463,7 +2456,7 @@ package actor ConnectionCore { } capabilities.states[key] = capability await reconcileCapability(for: key) - try await cleanup?.value() + try await cleanup?.valueIgnoringCancellation() capabilities.removeEmptyState(for: key) } @@ -2504,7 +2497,7 @@ package actor ConnectionCore { capability.activationWaiters.removeAll() capabilities.states[key] = capability for waiter in waiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } } else { capability.physical = .inactive(generation: expectedGeneration) @@ -2512,7 +2505,7 @@ package actor ConnectionCore { capability.releaseWaiters.removeAll() capabilities.states[key] = capability for waiter in waiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } } capabilities.removeEmptyState(for: key) @@ -2766,10 +2759,10 @@ package actor ConnectionCore { capability.releaseWaiters.removeAll() capabilities.states[key] = capability for waiter in waiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } for waiter in releaseWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } case let .failure(error): let activeLeaseFailed = capability.hasActivatedDesiredLease @@ -2809,10 +2802,10 @@ package actor ConnectionCore { } capabilities.states[key] = capability for waiter in activationWaiters { - await waiter.fulfill(.failure(error)) + waiter.fulfill(.failure(error)) } for waiter in releaseWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } if let claimedTerminalCause { await finishClaimedTermination(claimedTerminalCause) @@ -2829,7 +2822,7 @@ package actor ConnectionCore { capabilities.states[key] = capability await reconcileCapability(for: key) for waiter in releaseWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } case let .failure(error): if Self.isCommandRejection(error) { @@ -2845,10 +2838,10 @@ package actor ConnectionCore { capability.releaseWaiters.removeAll() capabilities.states[key] = capability for waiter in activationWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } for waiter in releaseWaiters { - await waiter.fulfill(.failure(error)) + waiter.fulfill(.failure(error)) } return } @@ -2867,10 +2860,10 @@ package actor ConnectionCore { capability.releaseWaiters.removeAll() capabilities.states[key] = capability for waiter in activationWaiters { - await waiter.fulfill(.failure(error)) + waiter.fulfill(.failure(error)) } for waiter in releaseWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } return } @@ -2889,10 +2882,10 @@ package actor ConnectionCore { capability.releaseWaiters.removeAll() capabilities.states[key] = capability for waiter in activationWaiters { - await waiter.fulfill(.failure(error)) + waiter.fulfill(.failure(error)) } for waiter in releaseWaiters { - await waiter.fulfill(.failure(error)) + waiter.fulfill(.failure(error)) } await finishClaimedTermination(claimedTerminalCause) } @@ -3042,7 +3035,7 @@ package actor ConnectionCore { 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( @@ -3085,7 +3078,7 @@ package actor ConnectionCore { 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( @@ -3168,7 +3161,7 @@ package actor ConnectionCore { 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 @@ -3190,13 +3183,7 @@ package actor ConnectionCore { 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 @@ -3211,14 +3198,14 @@ package actor ConnectionCore { let key = replyStore.takeTargetReplyKey(forRootWrapperID: id) { if parsed.errorMessage != nil, let pending = replyStore.removeTargetReply(for: key) { - await resolve(pending, key: .target(key), parsed: parsed) + resolve(pending, key: .target(key), parsed: parsed) } return } if let id = parsed.id, let pending = replyStore.removeRootReply(commandID: id) { - await resolve(pending, key: .root(id), parsed: parsed) + resolve(pending, key: .root(id), parsed: parsed) return } @@ -3318,7 +3305,7 @@ package actor ConnectionCore { if let id = parsed.id { let key = TransportSession.ReplyKey(targetID: targetID, commandID: id) if let pending = replyStore.removeTargetReply(for: key) { - await resolve(pending, key: .target(key), parsed: parsed) + resolve(pending, key: .target(key), parsed: parsed) return } } @@ -3374,10 +3361,10 @@ package actor ConnectionCore { _ pending: TransportSession.PendingReply, key: TransportSession.PendingKey, parsed: ParsedProtocolMessage - ) async { + ) { validateReplyOwnership(pending, key: key) if let errorMessage = parsed.errorMessage { - await pending.promise.fulfill( + pending.promise.fulfill( .failure( TransportSession.Error.remoteError( method: pending.method, @@ -3402,12 +3389,12 @@ package actor ConnectionCore { } catch { let message = "Failed to decode \(pending.method) reply: \(error)" handoffTermination(.protocolViolation(message)) - await pending.promise.fulfill( + pending.promise.fulfill( .failure(WebInspectorProxyError.protocolViolation(message)) ) return } - await pending.promise.fulfill( + pending.promise.fulfill( .success(result) ) } @@ -3979,7 +3966,7 @@ package actor ConnectionCore { } } for waiter in effects.releaseWaiters { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } } @@ -4002,7 +3989,7 @@ package actor ConnectionCore { _ mutation: RootEventMutation ) async { await completeCurrentPageBindingChange(mutation.bindingEffects) - await resumePhysicalTargetDisappearanceWaiters( + resumePhysicalTargetDisappearanceWaiters( mutation.physicalTargetWaiters ) await completeCommandInvalidationEffects(mutation.commandInvalidation) @@ -4060,12 +4047,12 @@ package actor ConnectionCore { private func resumePhysicalTargetDisappearanceWaiters( _ waiters: PhysicalTargetDisappearanceWaiters - ) async { + ) { for waiter in waiters.activation { - await waiter.fulfill(.failure(WebInspectorProxyError.pageUnavailable)) + waiter.fulfill(.failure(WebInspectorProxyError.pageUnavailable)) } for waiter in waiters.release { - await waiter.fulfill(.success(())) + waiter.fulfill(.success(())) } } @@ -4409,7 +4396,7 @@ package actor ConnectionCore { _ effects: EventEmissionEffects ) async { await completeCommandInvalidationEffects(effects.commandInvalidation) - await completeMainPageTargetNotification(effects.mainPageTargetNotification) + completeMainPageTargetNotification(effects.mainPageTargetNotification) } private func publishConfiguredModelEvent( @@ -4576,18 +4563,18 @@ package actor ConnectionCore { private func completeMainPageTargetNotification( _ notification: MainPageTargetNotification? - ) async { + ) { guard let notification else { return } for waiter in notification.waiters { - await waiter.fulfill(.success(notification.result)) + waiter.fulfill(.success(notification.result)) } } - private func failMainPageTargetWaiter(_ waiterID: UInt64, error: any Swift.Error) async { + private func failMainPageTargetWaiter(_ waiterID: UInt64, error: any Swift.Error) { let waiter = mainPageTargetWaiterStore.remove(id: waiterID) - await waiter?.fulfill(.failure(error)) + waiter?.fulfill(.failure(error)) } private func removeSubscriber(_ subscriberID: UInt64, domain: ProtocolDomain) { @@ -4602,7 +4589,7 @@ package actor ConnectionCore { 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): @@ -4611,10 +4598,10 @@ package actor ConnectionCore { 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): @@ -4622,7 +4609,7 @@ package actor ConnectionCore { case let .target(targetReplyKey): pending = replyStore.removeTargetReplyForTimeout(targetReplyKey) } - await pending?.promise.fulfill(.failure(error)) + pending?.promise.fulfill(.failure(error)) } private func markTargetReplyAsBufferedIfNeeded( diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 83d5fd69..2fd01bcf 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -1163,6 +1163,7 @@ func modelFeedCloseKeepsClaimUntilDisableAndSharesDuplicateCloseCompletion() asy try await feed.close() await duplicateFinished.finish() } + duplicateClose.cancel() await #expect(throws: ConnectionModelFeedError.alreadyOpen) { try await core.openModelFeed(configuredDomains: [], capacity: 8) diff --git a/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift b/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift new file mode 100644 index 00000000..a97ccf3d --- /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.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.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.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.wait() + waiter.cancel() + } + let fulfillment = Task { + await raceGate.wait() + return promise.fulfill(.success(value)) + } + await 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 +} From 70206ca98f1a9db05f3c41edeac747694a791ccd Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:39:22 +0900 Subject: [PATCH 42/81] fix(ui): replace stale page highlight operations --- .../Tree/DOMTreeTextView.swift | 34 +++- .../DOMTreeTextViewTests.swift | 150 +++++++++++++++++- 2 files changed, 175 insertions(+), 9 deletions(-) diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift index 050222c3..28d78ed8 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift @@ -70,6 +70,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat 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() @@ -155,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? @@ -1252,8 +1258,11 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat private func reconcilePageSelectionHighlightIntentIfNeeded() { guard case .selection(let nodeID) = pageHighlightIntent, - currentTreeSnapshot.selectedNodeID == nodeID, - pageHighlightTask == nil else { + currentTreeSnapshot.selectedNodeID == nodeID else { + return + } + let operation = PageHighlightOperation.highlight(nodeID, reason: .selection) + guard pageHighlightOperation != operation else { return } highlightPageNode(nodeID, reason: .selection) @@ -1263,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) @@ -1272,6 +1285,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat } let taskID = UUID() pageHighlightTaskID = taskID + pageHighlightOperation = operation pageHighlightTask = Task { @MainActor [weak self, highlightNodeAction] in await Task.yield() guard !Task.isCancelled, @@ -1299,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 } @@ -1311,11 +1326,16 @@ 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, @@ -1326,7 +1346,8 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat self.finishPageHighlightTask(id: taskID) } guard self.hoveredNodeID == nil else { - if self.pageHighlightIntent == .restoreSelectionAfterHover { + if self.pageHighlightTaskID == taskID, + self.pageHighlightIntent == .restoreSelectionAfterHover { self.pageHighlightIntent = nil } return @@ -1335,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 { @@ -1348,6 +1370,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat pageHighlightTask?.cancel() pageHighlightTask = nil pageHighlightTaskID = nil + pageHighlightOperation = nil if !preservingIntent { pageHighlightIntent = nil } @@ -1359,6 +1382,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat } pageHighlightTask = nil pageHighlightTaskID = nil + pageHighlightOperation = nil } private func presentDOMMenu(for nodeIDs: [DOMNode.ID], at location: CGPoint) { diff --git a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift index e3acb7be..7dd73869 100644 --- a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift @@ -375,6 +375,86 @@ struct DOMTreeTextViewTests { #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() @@ -1227,26 +1307,59 @@ private final class ControlledNodeActionRecorder { private struct IntentionalFailure: Error {} + private enum Gate { + case cancellationAware(WebInspectorTestGate) + case cancellationIgnoring(CancellationIgnoringGate) + + func wait() async { + switch self { + case let .cancellationAware(gate): + await gate.waiter.wait() + case let .cancellationIgnoring(gate): + await gate.wait() + } + } + + func open() async { + switch self { + case let .cancellationAware(gate): + await gate.open() + case let .cancellationIgnoring(gate): + await gate.open() + } + } + } + private var nodeIDs: [DOMNode.ID] = [] private var owners: [DOMTreePageHighlightOwner] = [] - private var gates: [WebInspectorTestGate] = [] + 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 = WebInspectorTestGate() + let gate: Gate = if ignoresCancellation { + .cancellationIgnoring(CancellationIgnoringGate()) + } else { + .cancellationAware(WebInspectorTestGate()) + } nodeIDs.append(nodeID) owners.append(owner) gates.append(gate) resumeInvocationWaitersIfNeeded() await gate.wait() - try Task.checkCancellation() - try Task.checkCancellation() + if !ignoresCancellation { + try Task.checkCancellation() + } if failedInvocationIndexes.contains(invocationIndex) { throw IntentionalFailure() } @@ -1301,6 +1414,35 @@ private final class ControlledNodeActionRecorder { } } +@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 From ac43af5578fbef80a19a6f78b5ff620f339c7543 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:40:41 +0900 Subject: [PATCH 43/81] fix(test): keep highlight gate self-contained --- Tests/WebInspectorUITests/DOMTreeTextViewTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift index 7dd73869..119d921b 100644 --- a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift @@ -1314,7 +1314,7 @@ private final class ControlledNodeActionRecorder { func wait() async { switch self { case let .cancellationAware(gate): - await gate.waiter.wait() + await gate.wait() case let .cancellationIgnoring(gate): await gate.wait() } From 12b29dce200601d77899de97260685dc98228b66 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:46:53 +0900 Subject: [PATCH 44/81] refactor(testing)!: replace semantic backend with raw peer BREAKING CHANGE: WebInspectorProxyKitTesting now exposes a production-path raw wire peer instead of semantic backend injection APIs. --- .../ContractTestSupport.swift | 555 +- .../StoryA2ProxyKitContractTests.swift | 104 +- .../StoryADataKitCompileContract.swift | 2 +- .../StoryADataKitFakeBackendTests.swift | 52 - .../StoryADataKitRawPeerTests.swift | 45 + Package.swift | 3 +- README.md | 36 +- .../WebInspectorContext.swift | 4 +- .../AnySendableValue.swift | 13 - .../LiveWebInspectorProxyBackend.swift | 4 +- .../Transport/TransportSession.swift | 24 - .../WebInspectorProxy.swift | 30 +- .../WebInspectorProxyKitTesting.md | 82 + .../WebInspectorProxyTestRuntime.swift | 63 +- .../WebInspectorTestBackend.swift | 567 - .../WebInspectorTestGate.swift | 40 - .../WebInspectorTestJSONObject.swift | 97 + .../WebInspectorTestPeer.swift | 785 ++ .../DataKitRawCommandAssertions.swift | 101 + .../DataKitRawEventFixtures.swift | 938 ++ .../DataKitRawResponseFixtures.swift | 336 + .../DataKitRawWireSupport.swift | 93 + .../WebInspectorDataKitTests.swift | 9603 +++++++++-------- .../AnySendableValueTests.swift | 41 - .../WebInspectorProxyKitBackendTests.swift | 708 +- ...torProxyTransportCommandBackendTests.swift | 58 +- .../WebInspectorTestPeerTests.swift | 451 + .../WebInspectorRawFixtures.swift | 98 + .../WebInspectorRawWireDriver.swift | 481 + .../WebInspectorTestGate.swift | 83 +- .../DOMContainerTests.swift | 233 +- .../DOMTreeTextViewTests.swift | 12 +- .../ParentContainerTests.swift | 17 +- 33 files changed, 9371 insertions(+), 6388 deletions(-) delete mode 100644 ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitFakeBackendTests.swift create mode 100644 ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift delete mode 100644 Sources/WebInspectorProxyKit/AnySendableValue.swift create mode 100644 Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md delete mode 100644 Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift delete mode 100644 Sources/WebInspectorProxyKitTesting/WebInspectorTestGate.swift create mode 100644 Sources/WebInspectorProxyKitTesting/WebInspectorTestJSONObject.swift create mode 100644 Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift create mode 100644 Tests/WebInspectorDataKitTests/DataKitRawCommandAssertions.swift create mode 100644 Tests/WebInspectorDataKitTests/DataKitRawEventFixtures.swift create mode 100644 Tests/WebInspectorDataKitTests/DataKitRawResponseFixtures.swift create mode 100644 Tests/WebInspectorDataKitTests/DataKitRawWireSupport.swift delete mode 100644 Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift create mode 100644 Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift create mode 100644 Tests/WebInspectorTestSupport/WebInspectorRawFixtures.swift create mode 100644 Tests/WebInspectorTestSupport/WebInspectorRawWireDriver.swift diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index cea1651f..62a0424c 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -5,102 +5,135 @@ 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) + 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, + ]) + } - 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 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 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 dataReceivedParameters() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "requestId": "contract-request", + "dataLength": 7, + "encodedDataLength": 4, + "timestamp": 3, + ]) } - 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 consoleMessageParameters(text: String) throws -> WebInspectorTestJSONObject { + try jsonObject([ + "message": [ + "source": "javascript", + "level": "log", + "text": text, + ], + ]) + } + + 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( @@ -134,61 +167,97 @@ enum ContractTestSupport { 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 { + private struct EvaluationSnapshot: Sendable { + let isException: Bool + let kind: Runtime.Kind + let value: Runtime.JSONValue? + let description: String? + let canRequestProperties: Bool + } + nonisolated let inspectorContainer: WebInspectorContainer private let runtime: WebInspectorProxyTestRuntime private var context: WebInspectorContext? + private var commands: [WebInspectorTestPeer.Command] init(runtime: WebInspectorProxyTestRuntime, inspectorContainer: WebInspectorContainer? = nil) { self.runtime = runtime let container = inspectorContainer ?? WebInspectorContainer(proxy: runtime.proxy) self.inspectorContainer = container context = nil + commands = [] } @discardableResult func start( - document: DOM.Node = WebInspectorProxyTestFixtures.domDocument(), - expectedSubscriberCount: Int = 1 + document: WebInspectorTestJSONObject? = nil, + sharesDomainLeases: Bool = false ) async throws -> WebInspectorTarget { + let documentResult: WebInspectorTestJSONObject + if let document { + documentResult = document + } else { + documentResult = try ContractTestSupport.documentResult() + } + 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 - ) + + if sharesDomainLeases == false { + var command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "Inspector.enable") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "Inspector.initialized") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "Runtime.enable") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "Network.enable") + try await runtime.peer.reply(to: command) + } + + var command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "DOM.getDocument") + try await runtime.peer.reply(to: command, with: documentResult) + + if sharesDomainLeases == false { + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "Console.enable") + try await runtime.peer.reply(to: command) + } + try await ContractTestSupport.waitUntil { context.state == .attached } return target } + func observedCommands() -> [WebInspectorTestPeer.Command] { + commands + } + func assertPublicSurfaceIsUsable() async throws { let context = modelContext() let requestResults: WebInspectorFetchedResults = context.fetchedResults() @@ -250,30 +319,18 @@ actor ContractDataKitActor { #expect(context.selectedContext == nil) } - func assertFakeBackendDrivesDOMNetworkAndRuntimeContracts() async throws { + func assertRawPeerDrivesDOMNetworkAndRuntimeContracts() async throws { let context = modelContext() - let document = WebInspectorProxyTestFixtures.domDocument( + 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 runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "DOM.setChildNodes", + parameters: ContractTestSupport.setChildNodesParameters() ) try await ContractTestSupport.waitUntil { @@ -297,42 +354,109 @@ actor ContractDataKitActor { #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) == "
") + let copyHTMLTask = Task { + try await child.copyText(.html, isolation: self) + } + var command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == "DOM.getOuterHTML") + try await runtime.peer.reply( + to: command, + with: ContractTestSupport.outerHTMLResult("
") + ) + #expect(try await copyHTMLTask.value == "
") #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) + let highlightTask = Task { + try await child.highlight(isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.highlightNode") + try await runtime.peer.reply(to: command) + try await highlightTask.value + + let hideHighlightTask = Task { + try await context.hideHighlight(isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.hideHighlight") + try await runtime.peer.reply(to: command) + try await hideHighlightTask.value + + let enablePickerTask = Task { + try await context.setElementPickerEnabled(true, isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.setInspectModeEnabled") + try await runtime.peer.reply(to: command) + try await enablePickerTask.value #expect(context.isElementPickerEnabled) - await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.setElementPickerEnabled(false) + + let disablePickerTask = Task { + try await context.setElementPickerEnabled(false, isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.setInspectModeEnabled") + try await runtime.peer.reply(to: command) + try await disablePickerTask.value #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 deleteTask = Task { + try await child.delete(isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.removeNode") + try await runtime.peer.reply(to: command) + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "DOM.markUndoableState") + try await runtime.peer.reply(to: command) + try await deleteTask.value + + let reloadTask = Task { + try await context.reloadPage(isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Page.reload") + try await runtime.peer.reply(to: command) + try await reloadTask.value + + #expect(commands.contains { $0.method == "DOM.getOuterHTML" }) + #expect(commands.contains { $0.method == "DOM.highlightNode" }) + #expect(commands.contains { $0.method == "DOM.hideHighlight" }) + #expect(commands.contains { $0.method == "DOM.setInspectModeEnabled" }) + #expect(commands.contains { $0.method == "DOM.removeNode" }) + #expect(commands.contains { $0.method == "DOM.markUndoableState" }) + #expect(commands.contains { $0.method == "Page.reload" }) + let requestResults: WebInspectorFetchedResults = context.fetchedResults() - await ContractTestSupport.emitFinishedRequest(request, target: target, backend: runtime.backend) + 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.dataReceived", + parameters: ContractTestSupport.dataReceivedParameters() + ) + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.loadingFinished", + parameters: ContractTestSupport.loadingFinishedParameters() + ) let requests: WebInspectorFetchedResults = context.fetchedResults() try await ContractTestSupport.waitUntil { @@ -358,35 +482,59 @@ actor ContractDataKitActor { #expect(context.registeredRequest(for: requestModel.id) === requestModel) #expect(requestResults.snapshot.itemIDs == [requestModel.id]) - await runtime.backend.enqueue( - Network.Body(data: "{\"ok\":true}", base64Encoded: false), - for: "Network", - method: "getResponseBody" + let bodyTask = Task { + await requestModel.fetchResponseBody(isolation: self) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Network.getResponseBody") + try await runtime.peer.reply( + to: command, + with: ContractTestSupport.responseBodyResult( + "{\"ok\":true}", + base64Encoded: false + ) ) - await requestModel.fetchResponseBody() + await bodyTask.value #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" + let evaluationTask = Task { + let evaluation = try await context.evaluate( + "document.title", + isolation: self + ) + return EvaluationSnapshot( + isException: evaluation.isException, + kind: evaluation.object.kind, + value: evaluation.object.value, + description: evaluation.object.description, + canRequestProperties: evaluation.object.canRequestProperties + ) + } + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Runtime.evaluate") + try await runtime.peer.reply( + to: command, + with: ContractTestSupport.evaluationResult() ) - let evaluation = try await context.evaluate("document.title") + let evaluation = try await evaluationTask.value #expect(evaluation.isException == false) - #expect(evaluation.object.kind == .string) - #expect(evaluation.object.value == .string("contract")) - #expect(evaluation.object.description == "contract") - #expect(evaluation.object.canRequestProperties) + #expect(evaluation.kind == .string) + #expect(evaluation.value == .string("contract")) + #expect(evaluation.description == "contract") + #expect(evaluation.canRequestProperties) + } + + func emitConsoleMessage(text: String) async throws { + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Console.messageAdded", + parameters: ContractTestSupport.consoleMessageParameters(text: text) + ) } func waitForConsoleMessage(text: String) async throws { @@ -410,20 +558,43 @@ actor ContractDataKitActor { #expect(Set([model]).contains(model)) } - func stopContext(enqueueShutdownReplies: Bool = true) async { + func stopContext(expectsDomainDisableCommands: Bool = true) async throws { guard let context else { return } - if enqueueShutdownReplies { - await ContractTestSupport.enqueueDataKitShutdownReplies(on: runtime.backend) + let stopTask = Task { + await context.stop(isolation: self) } - await context.stop() + + if expectsDomainDisableCommands { + var command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Console.disable") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Runtime.disable") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Network.disable") + try await runtime.peer.reply(to: command) + + command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.method == "Inspector.disable") + try await runtime.peer.reply(to: command) + } + + await stopTask.value #expect(context.state == .detached) #expect(context.teardownError == nil) } - func close() async { - await stopContext() + func close() async throws { + try await stopContext() await inspectorContainer.close() } } diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift index e1ad4c74..befb2bce 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift @@ -1,7 +1,31 @@ +import Foundation import Testing import WebInspectorProxyKit import WebInspectorProxyKitTesting +@Test +func testJSONObjectPublicInitializersValidateAndCanonicalizeTypedFixtures() throws { + struct Fixture: Codable, Equatable { + let name: String + let count: Int + } + + 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(throws: WebInspectorTestPeerError.invalidJSONObject) { + try WebInspectorTestJSONObject(encoding: [1, 2]) + } +} + @Test func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() @@ -40,12 +64,14 @@ func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() currentPageCommands ) - await runtime.backend.enqueue((), for: "Network", method: "enable") - try await targetNetwork.enable() - - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Network", method: "enable"))) - #expect(commands.first?.targetID == target.id) + let enableTask = Task { + try await targetNetwork.enable() + } + let 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) + try await enableTask.value await runtime.proxy.close() try await runtime.proxy.waitUntilClosed() @@ -57,29 +83,56 @@ func webInspectorProxyNetworkEventsMulticastToConsumerSubscribers() async throws let runtime = try await WebInspectorProxyTestRuntime.start() let target = try await runtime.proxy.waitForCurrentPage() - 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 target.network.withEvents { firstEvents in + try await target.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.") @@ -99,6 +152,7 @@ func webInspectorProxyNetworkEventsMulticastToConsumerSubscribers() async throws #expect(secondType == .fetch) #expect(firstTimestamp == 42) #expect(secondTimestamp == 42) + await runtime.close() } private func domStructuredEventSurfaceCompiles(_ handle: DOM) async throws { 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..9cad19fe --- /dev/null +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift @@ -0,0 +1,45 @@ +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() +} + +@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) + + try await firstOwner.start() + try await secondOwner.start(sharesDomainLeases: true) + + var commands = await firstOwner.observedCommands() + secondOwner.observedCommands() + #expect(commands.filter { $0.method == "Runtime.enable" }.count == 1) + #expect(commands.filter { $0.method == "Network.enable" }.count == 1) + #expect(commands.filter { $0.method == "Console.enable" }.count == 1) + + try await firstOwner.stopContext(expectsDomainDisableCommands: false) + + commands = await firstOwner.observedCommands() + secondOwner.observedCommands() + #expect(commands.contains { $0.method == "Runtime.disable" } == false) + #expect(commands.contains { $0.method == "Network.disable" } == false) + #expect(commands.contains { $0.method == "Console.disable" } == false) + + try await secondOwner.emitConsoleMessage(text: "second-context-still-live") + try await secondOwner.waitForConsoleMessage(text: "second-context-still-live") + + try await secondOwner.close() + + commands = await firstOwner.observedCommands() + secondOwner.observedCommands() + #expect(commands.filter { $0.method == "Runtime.disable" }.count == 1) + #expect(commands.filter { $0.method == "Network.disable" }.count == 1) + #expect(commands.filter { $0.method == "Console.disable" }.count == 1) +} diff --git a/Package.swift b/Package.swift index 15529465..76852868 100644 --- a/Package.swift +++ b/Package.swift @@ -163,7 +163,8 @@ let package = Package( .target( name: "WebInspectorTestSupport", dependencies: [ - "WebInspectorProxyKit" + "WebInspectorProxyKit", + "WebInspectorProxyKitTesting", ], path: "Tests/WebInspectorTestSupport", swiftSettings: strictSwiftSettings diff --git a/README.md b/README.md index 620b70c6..8ef2384a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ 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. | | `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 @@ -87,6 +87,38 @@ 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 page = try await runtime.proxy.waitForCurrentPage() + +let reload = Task { + try await 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. + ## Documentation The DocC workflow publishes [package documentation](https://lynnswap.github.io/WebInspectorKit/documentation/) @@ -105,7 +137,7 @@ Sources/ WebInspectorKit/ Public built-in inspector product. WebInspectorDataKit/ Observable inspector model product. 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/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index f24609f3..90a01923 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -197,14 +197,14 @@ public final class WebInspectorContext { } package static func preview(isolation: isolated (any Actor)) -> WebInspectorContext { - let container = WebInspectorContainer(proxy: WebInspectorProxy()) + let container = WebInspectorContainer(proxy: WebInspectorProxy(localStateOnly: ())) 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 container = WebInspectorContainer(proxy: WebInspectorProxy(localStateOnly: ())) let context = WebInspectorContext(container, isolation: isolation) context.state = .detached return context diff --git a/Sources/WebInspectorProxyKit/AnySendableValue.swift b/Sources/WebInspectorProxyKit/AnySendableValue.swift deleted file mode 100644 index c083b003..00000000 --- a/Sources/WebInspectorProxyKit/AnySendableValue.swift +++ /dev/null @@ -1,13 +0,0 @@ -package struct AnySendableValue: Sendable { - package let value: any Sendable - - package init(_ value: Value) { - self.value = value - } - - package func cast( - as type: Value.Type = Value.self - ) -> Value? { - value as? Value - } -} diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index cada2c30..46a2ebaa 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -125,9 +125,9 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return WebInspectorProxyError.closed case let .transportFailure(message): return WebInspectorProxyError.disconnected(message) - case let .replyTimeout(method, _): + 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( diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index e4c4e0a3..6398b79f 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -354,7 +354,6 @@ package actor ConnectionCore { private var closeWaiters: [UInt64: CheckedContinuation] private var closeWaiterRegistrationWaiters: [CheckedContinuation] private var cancelledCloseWaiterIDs: Set - private var nextTestTargetOrdinal: UInt64 private var modelTargetMutationActionForTesting: (@Sendable () -> Void)? package init( @@ -410,7 +409,6 @@ package actor ConnectionCore { closeWaiters = [:] closeWaiterRegistrationWaiters = [] cancelledCloseWaiterIDs = [] - nextTestTargetOrdinal = 0 modelTargetMutationActionForTesting = nil } @@ -1720,28 +1718,6 @@ package actor ConnectionCore { return targetRegistry.target(for: targetID) } - package func installTargetForTesting( - kind: ProtocolTarget.Kind, - frameID: ProtocolFrame.ID?, - isProvisional: Bool - ) -> ProtocolTarget.Record { - precondition(isOpen, "Cannot install a target after ConnectionCore starts closing.") - let ordinal = nextTestTargetOrdinal - nextTestTargetOrdinal &+= 1 - let id = ProtocolTarget.ID("test-target-\(ordinal)") - let record = ProtocolTarget.Record( - id: id, - kind: kind, - frameID: frameID, - parentFrameID: nil, - capabilities: .resolved(for: kind, domainNames: nil), - isProvisional: isProvisional, - isPaused: false - ) - _ = targetRegistry.recordTargetCreated(record) - return record - } - private func requireAvailableTarget(for route: RoutingTargetID) throws { switch route.storage { case let .target(rawValue): diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index c9754d7c..98e6befe 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -105,15 +105,14 @@ 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 + configuration = .init() + backend = nil core = ConnectionCore( backend: UnavailableTransportBackend(), - responseTimeout: configuration.responseTimeout, + responseTimeout: nil, closeAction: closeConnection ) } @@ -239,27 +238,6 @@ public actor WebInspectorProxy { try await core.waitUntilClosed() } - package func installTargetForTesting( - kind: WebInspectorTarget.Kind = .page, - frameID: FrameID? = nil, - isProvisional: Bool = false - ) async -> WebInspectorTarget { - let record = await core.installTargetForTesting( - kind: kind.protocolKind, - frameID: frameID.map { ProtocolFrame.ID($0.rawValue) }, - isProvisional: isProvisional - ) - let target = WebInspectorTarget( - id: WebInspectorTarget.ID(record.id.rawValue), - kind: kind, - frameID: frameID, - isProvisional: isProvisional, - proxy: self, - route: RoutingTargetID(record.id.rawValue) - ) - return target - } - package func waitForCloseWaiterForTesting() async { await core.waitForCloseWaiterForTesting() } diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md new file mode 100644 index 00000000..0937085e --- /dev/null +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md @@ -0,0 +1,82 @@ +# ``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 page = try await runtime.proxy.waitForCurrentPage() + +let reload = Task { + try await 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 014ffe7d..00000000 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift +++ /dev/null @@ -1,567 +0,0 @@ -import Foundation -import WebInspectorProxyKit - -/// 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: AnySendableValue - - /// 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 = AnySendableValue(()) - } - - package init( - command: WebInspectorProxyCommand - ) { - targetID = command.targetID - route = command.route - domain = command.domain.rawValue - method = command.method - payload = AnySendableValue(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: Sendable { - enum Storage: Sendable { - case result(AnySendableValue) - case failure(any Error & Sendable) - } - - var storage: Storage - - init(_ value: some Sendable) { - storage = .result(AnySendableValue(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 - - switch queued.storage { - case let .result(erasedResult): - guard let result = erasedResult.cast(as: Result.self) else { - throw WebInspectorProxyError.commandFailed( - domain: command.domain.rawValue, - method: command.method, - message: "Enqueued result for \(command.domain.rawValue).\(command.method) has type " - + "\(type(of: erasedResult.value)); expected \(Result.self)." - ) - } - return result - case let .failure(error): - throw error - } - } - - 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..c3ee61f8 --- /dev/null +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift @@ -0,0 +1,785 @@ +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] + + init() { + let commandMailbox = WebInspectorTestCommandMailbox() + peerID = UUID() + self.commandMailbox = commandMailbox + commands = Commands(mailbox: commandMailbox) + core = nil + receiver = nil + state = .unattached + nextCommandOrdinal = 0 + outstandingCommands = [:] + } + + 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 + } + + 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 + } + for message in messages { + receiver.receive(message) + } + let through = receiver.tailOrdinal() + await receiver.waitUntilDrained(through: through) + do { + try await admittedCore.requireOpen() + } catch { + finishConnection() + throw WebInspectorTestPeerError.connectionClosed + } + guard case .open = state, core === admittedCore else { + 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/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/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 0c87b29b..e1f07cab 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -8,615 +8,634 @@ 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) - ) + try await withDataKitTestRuntime { runtime in + 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") + + try await enqueueStartupReplies( + on: runtime.wire, + 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 - ) - let child = try await waitForChild(in: context) - #expect(context.node(for: child.id) === child) + 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( - .attributeModified(childID, name: "class", value: "after"), - target: target - ) - try await waitUntil { child.attributes["class"] == "after" } - #expect(context.node(for: child.id) === child) + try await runtime.wire.emitRaw( + .setChildNodes(parent: documentID, nodes: [ + DOM.Node( + id: childID, + nodeType: 1, + nodeName: "DIV", + localName: "div", + attributes: ["class": "before"], + childNodeCount: 0 + ) + ]), + target: target + ) + let child = try await waitForChild(in: context) + #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) + try await runtime.wire.emitRaw( + .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( - .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 + try await runtime.wire.emitRaw( + .childNodeCountUpdated(childID, count: 2), + target: target + ) + try await waitUntil { child.childNodeCount == 2 } + #expect(context.node(for: child.id) === child) + + try await runtime.wire.emitRaw( + .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) } - return children.first?.id == DOMNode.ID(grandchildID) - } - guard case let .loaded(grandchildren) = child.children else { - Issue.record("Expected loaded child subtree.") - return - } - let grandchild = try #require(grandchildren.first) - context.select(grandchild) + 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 + try await runtime.wire.emitRaw( + .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 } - return children.isEmpty + #expect(context.node(for: child.id) == nil) + #expect(context.node(for: grandchild.id) == nil) + #expect(context.selectedNode == nil) } - #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") - - await document.requestChildren(depth: 2) - - 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 withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let childID = DOM.Node.ID("requested-child") + + await runtime.wire.respond(to: "DOM.requestChildNodes") + + await document.requestChildren(depth: 2) + + let commands = runtime.wire.observations.commands + let command = try #require(commands.first { + $0.method == "DOM.requestChildNodes" + }) + #expect(try commandStringParameter(command, "nodeId") == document.id.proxyID.rawValue) + #expect(try commandIntegerParameter(command, "depth") == 2) + + try await runtime.wire.emitRaw( + .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 + try await waitUntil { + guard case let .loaded(children) = document.children else { + return false + } + return children.first?.id == DOMNode.ID(childID) } - return children.first?.id == DOMNode.ID(childID) } } @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 + try await withDataKitTestRuntime { runtime in + 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 - #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(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()") + } } @MainActor @Test func domCommandsDispatchThroughDataKitContext() 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 parentID = DOM.Node.ID("parent") - let childID = DOM.Node.ID("child") - 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: 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")] - ) - ] - ) - ] - ) - ] - ) - ] - ) - 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.highlightNode(for: child.id) + try await withDataKitTestRuntime { runtime in + 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, + 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: 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")] + ) + ] + ) + ] + ) + ] + ) + ] + ) + 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: "hideHighlight") - try await context.hideHighlight() + await runtime.wire.respond( + to: "DOM.getOuterHTML", + with: try rawOuterHTMLResult("") + ) + #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: "undo") - try await context.undoDOMChange() + await runtime.wire.respond(to: "DOM.highlightNode") + try await context.highlightNode(for: child.id) - await runtime.backend.enqueue((), for: "DOM", method: "redo") - try await context.redoDOMChange() + await runtime.wire.respond(to: "DOM.hideHighlight") + try await context.hideHighlight() - await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.setElementPickerEnabled(true) - #expect(context.isElementPickerEnabled) + await runtime.wire.respond(to: "DOM.undo") + try await context.undoDOMChange() - 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.wire.respond(to: "DOM.redo") + try await context.redoDOMChange() - await runtime.backend.enqueue((), for: "DOM", method: "setAttributeValue") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.setDOMAttribute( - "class", - value: "updated", - on: parent.id, - options: .automatic - ) + await runtime.wire.respond(to: "DOM.setInspectModeEnabled") + try await context.setElementPickerEnabled(true) + #expect(context.isElementPickerEnabled) + + try await enqueueCSSStyleReplies(on: runtime.wire) + await runtime.wire.respond(to: "DOM.highlightNode") + try await runtime.wire.emitRaw(.inspect(childID), target: target) + try await waitUntil { context.selectedNode === child } + try await waitUntil { child.elementStyles?.phase == .loaded } + #expect(context.isElementPickerEnabled == false) + + await runtime.wire.respond(to: "DOM.setAttributeValue") + await runtime.wire.respond(to: "DOM.markUndoableState") + try await context.setDOMAttribute( + "class", + value: "updated", + on: parent.id, + options: .automatic + ) - await runtime.backend.enqueue((), for: "DOM", method: "setOuterHTML") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.setDOMOuterHTML( - "", - of: child.id, - options: .automatic - ) + await runtime.wire.respond(to: "DOM.setOuterHTML") + await runtime.wire.respond(to: "DOM.markUndoableState") + try await context.setDOMOuterHTML( + "", + of: child.id, + options: .automatic + ) - 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.removeDOMNodes( - [parent.id, child.id], - options: .automatic - ) - #expect(deletion.acceptedNodeIDs == [child.id, parent.id]) - #expect(context.selectedNode == nil) - #expect(child.elementStyles == nil) + await runtime.wire.respond(to: "DOM.removeNode") + await runtime.wire.respond(to: "DOM.removeNode") + await runtime.wire.respond(to: "DOM.markUndoableState") + await runtime.wire.respond(to: "DOM.markUndoableState") + let deletion = try await context.removeDOMNodes( + [parent.id, child.id], + options: .automatic + ) + #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.reloadPage(ignoringCache: true) + await runtime.wire.respond(to: "Page.reload") + try await context.reloadPage(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 commands = runtime.wire.observations.commands + let outerHTML = try #require(commands.first { $0.method == "DOM.getOuterHTML" }) + #expect(try commandStringParameter(outerHTML, "nodeId") == childID.rawValue) - let highlight = try #require(commands.first { $0.domain == "DOM" && $0.method == "highlightNode" }) - #expect(highlight.payload.cast(as: DOM.HighlightNodePayload.self)?.id == childID) + let highlight = try #require(commands.first { $0.method == "DOM.highlightNode" }) + #expect(try commandStringParameter(highlight, "nodeId") == childID.rawValue) - #expect(commands.contains { $0.domain == "DOM" && $0.method == "undo" }) - #expect(commands.contains { $0.domain == "DOM" && $0.method == "redo" }) + #expect(commands.contains { $0.method == "DOM.undo" }) + #expect(commands.contains { $0.method == "DOM.redo" }) - let inspectMode = try #require(commands.first { $0.domain == "DOM" && $0.method == "setInspectModeEnabled" }) - #expect(inspectMode.payload.cast(as: DOM.SetInspectModeEnabledPayload.self)?.enabled == true) + let inspectMode = try #require(commands.first { $0.method == "DOM.setInspectModeEnabled" }) + #expect(try commandBooleanParameter(inspectMode, "enabled")) - 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 setAttribute = try #require(commands.first { $0.method == "DOM.setAttributeValue" }) + #expect(try commandStringParameter(setAttribute, "nodeId") == parentID.rawValue) + #expect(try commandStringParameter(setAttribute, "name") == "class") + #expect(try commandStringParameter(setAttribute, "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 setOuterHTML = try #require(commands.first { $0.method == "DOM.setOuterHTML" }) + #expect(try commandStringParameter(setOuterHTML, "nodeId") == childID.rawValue) + #expect(try commandStringParameter(setOuterHTML, "outerHTML") == "") - 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) + let removals = commands.filter { $0.method == "DOM.removeNode" } + #expect(removals.count == 2) + let firstRemoval = try #require(removals.first) + let lastRemoval = try #require(removals.last) + #expect(try commandStringParameter(firstRemoval, "nodeId") == childID.rawValue) + #expect(try commandStringParameter(lastRemoval, "nodeId") == parentID.rawValue) + let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } + #expect(undoMarks.count == 4) - let reload = try #require(commands.first { $0.domain == "Page" && $0.method == "reload" }) - #expect(reload.payload.cast(as: Page.ReloadPayload.self)?.ignoringCache == true) + let reload = try #require(commands.first { $0.method == "Page.reload" }) + #expect(try commandBooleanParameter(reload, "ignoreCache")) + } } @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.setDOMAttribute( - "data-edited", - value: "page", - on: document.id, - options: .automatic - ) - - await runtime.backend.enqueue((), for: "DOM", method: "removeNode") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - _ = try await context.removeDOMNodes( - [DOMNode.ID(scopedNodeID)], - options: .automatic - ) - - await runtime.backend.enqueue((), for: "DOM", method: "undo") - try await context.undoDOMChange() - - await runtime.backend.enqueue((), for: "DOM", method: "redo") - try await context.redoDOMChange() - - let domCommands = await runtime.backend.recordedCommands() - .filter { $0.domain == "DOM" && ["setAttributeValue", "removeNode", "markUndoableState", "undo", "redo"].contains($0.method) } - #expect(domCommands.map(\.method) == [ - "setAttributeValue", - "markUndoableState", - "removeNode", - "markUndoableState", - "undo", - "redo", - ]) - #expect(domCommands.prefix(2).allSatisfy { $0.targetID == target.id }) - let frameCommands = domCommands.dropFirst(2) - #expect(frameCommands.allSatisfy { $0.targetID == frameTarget.id }) - #expect(frameCommands.allSatisfy { $0.route == RoutingTargetID(frameTarget.id.rawValue) }) - let removal = try #require(frameCommands.first { $0.method == "removeNode" }) - #expect(removal.payload.cast(as: DOM.RemoveNodePayload.self)?.id == scopedNodeID) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let frameTarget = try await createFrameTarget(in: runtime) + let document = try #require(context.rootNode) + let scopedNodeID = DOM.Node.ID( + "frame-owned-node", + scopedToTargetRawValue: frameTarget.id.rawValue + ) + + try await runtime.wire.emitRaw( + .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.wire.respond(to: "DOM.setAttributeValue") + await runtime.wire.respond(to: "DOM.markUndoableState") + try await context.setDOMAttribute( + "data-edited", + value: "page", + on: document.id, + options: .automatic + ) + + await runtime.wire.respond(to: "DOM.removeNode") + await runtime.wire.respond(to: "DOM.markUndoableState") + _ = try await context.removeDOMNodes( + [DOMNode.ID(scopedNodeID)], + options: .automatic + ) + + await runtime.wire.respond(to: "DOM.undo") + try await context.undoDOMChange() + + await runtime.wire.respond(to: "DOM.redo") + try await context.redoDOMChange() + + let domCommands = runtime.wire.observations.commands + .filter { + [ + "DOM.setAttributeValue", + "DOM.removeNode", + "DOM.markUndoableState", + "DOM.undo", + "DOM.redo", + ].contains($0.method) + } + #expect(domCommands.map(\.method) == [ + "DOM.setAttributeValue", + "DOM.markUndoableState", + "DOM.removeNode", + "DOM.markUndoableState", + "DOM.undo", + "DOM.redo", + ]) + #expect(domCommands.prefix(2).allSatisfy { $0.destination == .target(wireTargetID(target)) }) + let frameCommands = domCommands.dropFirst(2) + #expect(frameCommands.allSatisfy { $0.destination == .target(wireTargetID(frameTarget)) }) + let removal = try #require(frameCommands.first { $0.method == "DOM.removeNode" }) + #expect(try commandStringParameter(removal, "nodeId") == scopedNodeID.unscopedRawValue) + } } @MainActor @Test func domDeleteRejectsCrossTargetSelectionBeforeRemovingNodes() 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 pageNodeID = DOM.Node.ID("page-node") - let scopedFrameNodeID = DOM.Node.ID( - "frame-node", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: pageNodeID, nodeType: 1, nodeName: "DIV", localName: "div"), - DOM.Node(id: scopedFrameNodeID, nodeType: 1, nodeName: "SPAN", localName: "span"), - ]), - target: target - ) - try await waitUntil { - context.node(for: DOMNode.ID(pageNodeID)) != nil - && context.node(for: DOMNode.ID(scopedFrameNodeID)) != nil - } + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let frameTarget = try await createFrameTarget(in: runtime) + let document = try #require(context.rootNode) + let pageNodeID = DOM.Node.ID("page-node") + let scopedFrameNodeID = DOM.Node.ID( + "frame-node", + scopedToTargetRawValue: frameTarget.id.rawValue + ) - await #expect(throws: WebInspectorProxyError.commandFailed( - domain: "DOM", - method: "removeNode", - message: "Deleting nodes from multiple DOM targets in one mutation is not supported." - )) { - _ = try await context.removeDOMNodes([ - DOMNode.ID(pageNodeID), - DOMNode.ID(scopedFrameNodeID), - ], options: .automatic) - } + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: pageNodeID, nodeType: 1, nodeName: "DIV", localName: "div"), + DOM.Node(id: scopedFrameNodeID, nodeType: 1, nodeName: "SPAN", localName: "span"), + ]), + target: target + ) + try await waitUntil { + context.node(for: DOMNode.ID(pageNodeID)) != nil + && context.node(for: DOMNode.ID(scopedFrameNodeID)) != nil + } + + await #expect(throws: WebInspectorProxyError.commandFailed( + domain: "DOM", + method: "removeNode", + message: "Deleting nodes from multiple DOM targets in one mutation is not supported." + )) { + _ = try await context.removeDOMNodes([ + DOMNode.ID(pageNodeID), + DOMNode.ID(scopedFrameNodeID), + ], options: .automatic) + } - let removeCommands = await runtime.backend.recordedCommands() - .filter { $0.domain == "DOM" && $0.method == "removeNode" } - #expect(removeCommands.isEmpty) + let removeCommands = runtime.wire.observations.commands + .filter { $0.method == "DOM.removeNode" } + #expect(removeCommands.isEmpty) + } } @MainActor @Test func domInspectSelectsKnownNodeAndLoadsStyles() 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("inspect-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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("inspect-node") + + try await runtime.wire.emitRaw( + .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) - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - await runtime.backend.emit(.inspect(elementID), target: target) + try await enqueueCSSStyleReplies(on: runtime.wire) + await runtime.wire.respond(to: "DOM.highlightNode") + try await runtime.wire.emitRaw(.inspect(elementID), target: target) - try await waitUntil { context.selectedNode === element } - try await waitUntil { - await runtime.backend.recordedCommands().contains { command in - command.domain == "DOM" - && command.method == "highlightNode" - && command.payload.cast(as: DOM.HighlightNodePayload.self)?.id == elementID + try await waitUntil { context.selectedNode === element } + try await waitUntil { + runtime.wire.observations.commands.contains { command in + command.method == "DOM.highlightNode" + && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue + } } - } - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(styles.sections.map(\.title) == [".card"]) + let styles = try #require(element.elementStyles) + try await waitUntil { styles.phase == .loaded } + #expect(styles.sections.map(\.title) == [".card"]) - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode"))) - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getComputedStyleForNode"))) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" }) + #expect(commands.contains { $0.method == "CSS.getComputedStyleForNode" }) + } } @MainActor @Test func domInspectWaitsForRequestNodePathBeforeSelectingUnresolvedNode() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let staleID = DOM.Node.ID("stale-node") - let elementID = DOM.Node.ID("resolved-inspect-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: staleID, nodeType: 1, nodeName: "SPAN", localName: "span") - ]), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(staleID)) != nil } - - await enqueueCSSStyleReplies(on: runtime.backend) - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - await runtime.backend.emit(.inspect(elementID), target: target) - #expect(context.selectedNode == nil) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let staleID = DOM.Node.ID("stale-node") + let elementID = DOM.Node.ID("resolved-inspect-node") + + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: staleID, nodeType: 1, nodeName: "SPAN", localName: "span") + ]), + target: target + ) + try await waitUntil { context.node(for: DOMNode.ID(staleID)) != nil } + + try await enqueueCSSStyleReplies(on: runtime.wire) + await runtime.wire.respond(to: "DOM.highlightNode") + try await runtime.wire.emitRaw(.inspect(elementID), target: target) + #expect(context.selectedNode == nil) + + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") + ]), + target: target + ) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } - try await waitUntil { - await runtime.backend.recordedCommands().contains { command in - command.domain == "DOM" - && command.method == "highlightNode" - && command.payload.cast(as: DOM.HighlightNodePayload.self)?.id == elementID + try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } + try await waitUntil { + runtime.wire.observations.commands.contains { command in + command.method == "DOM.highlightNode" + && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue + } } + #expect(context.state == .attached) + #expect(runtime.wire.observations.commandMethods.contains( + "DOM.requestChildNodes" + ) == false) + #expect(context.node(for: DOMNode.ID(staleID)) == nil) + let selected = try #require(context.selectedNode) + let styles = try #require(selected.elementStyles) + try await waitUntil { styles.phase == .loaded } } - #expect(context.state == .attached) - #expect(await runtime.backend.recordedCommands().contains( - RecordedCommand(domain: "DOM", method: "requestChildNodes") - ) == false) - #expect(context.node(for: DOMNode.ID(staleID)) == nil) - let selected = try #require(context.selectedNode) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } } @MainActor @Test func domInspectBeforeDocumentArrivesWaitsForRequestNodePathAfterRootApplies() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let gate = WebInspectorTestGate() - let documentID = DOM.Node.ID("document") - let elementID = DOM.Node.ID("deferred-inspect-node") - - await runtime.backend.hold(domain: "DOM", method: "getDocument", gate: gate) - 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 { - await runtime.backend.recordedCommands() - .contains(RecordedCommand(domain: "DOM", method: "getDocument")) - } + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let documentID = DOM.Node.ID("document") + let elementID = DOM.Node.ID("deferred-inspect-node") + + let gate = await runtime.wire.deferReply( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) + ) + await enqueueDomainEnableReplies(on: runtime.wire) + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + runtime.wire.observations.commands + .contains { $0.method == "DOM.getDocument" } + } - await runtime.backend.emit(.inspect(elementID), target: target) - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - await enqueueCSSStyleReplies(on: runtime.backend) - await gate.open() + try await runtime.wire.emitRaw(.inspect(elementID), target: target) + await runtime.wire.respond(to: "DOM.highlightNode") + try await enqueueCSSStyleReplies(on: runtime.wire) + gate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(documentID) } - #expect(context.selectedNode == nil) + try await waitUntil { context.rootNode?.id == DOMNode.ID(documentID) } + #expect(context.selectedNode == nil) - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) + try await runtime.wire.emitRaw( + .setChildNodes(parent: documentID, nodes: [ + DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") + ]), + target: target + ) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } - try await waitUntil { - await runtime.backend.recordedCommands().contains { command in - command.domain == "DOM" - && command.method == "highlightNode" - && command.payload.cast(as: DOM.HighlightNodePayload.self)?.id == elementID + try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } + try await waitUntil { + runtime.wire.observations.commands.contains { command in + command.method == "DOM.highlightNode" + && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue + } } + let selected = try #require(context.selectedNode) + let styles = try #require(selected.elementStyles) + try await waitUntil { styles.phase == .loaded } + #expect(runtime.wire.observations.commandMethods.contains( + "DOM.requestChildNodes" + ) == false) } - let selected = try #require(context.selectedNode) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(await runtime.backend.recordedCommands().contains( - RecordedCommand(domain: "DOM", method: "requestChildNodes") - ) == false) } @MainActor @Test func explicitSelectionSupersedesPendingDOMInspectResolution() 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("manual-selection") - let inspectedID = DOM.Node.ID("late-inspect-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button") - ]), - target: target - ) - let manualSelection = try await waitForChild(in: context) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let selectedID = DOM.Node.ID("manual-selection") + let inspectedID = DOM.Node.ID("late-inspect-node") + + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button") + ]), + target: target + ) + let manualSelection = try await waitForChild(in: context) - context.apply(.inspect(inspectedID)) + context.apply(.inspect(inspectedID)) - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(manualSelection) - try await waitUntil { context.selectedNode === manualSelection } + try await enqueueCSSStyleReplies(on: runtime.wire) + context.select(manualSelection) + try await waitUntil { context.selectedNode === manualSelection } - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button"), - DOM.Node(id: inspectedID, nodeType: 1, nodeName: "DIV", localName: "div"), - ]), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(inspectedID)) != nil } + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button"), + DOM.Node(id: inspectedID, nodeType: 1, nodeName: "DIV", localName: "div"), + ]), + target: target + ) + try await waitUntil { context.node(for: DOMNode.ID(inspectedID)) != nil } - #expect(context.selectedNode === manualSelection) - #expect(await runtime.backend.recordedCommands().contains( - RecordedCommand(domain: "DOM", method: "requestChildNodes") - ) == false) + #expect(context.selectedNode === manualSelection) + #expect(runtime.wire.observations.commandMethods.contains( + "DOM.requestChildNodes" + ) == false) + } } @MainActor @@ -625,496 +644,525 @@ func domMutationEventForUnmaterializedNodeIsSkipped() async throws { // Live pages emit mutation events for nodes WebKit has bound for this // frontend but this context has not materialized (attach mid-flight, // evicted subtrees). They must be skipped, not fail the context. - 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") - - 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( - .attributeModified(DOM.Node.ID("unmaterialized"), name: "class", value: "x"), - target: target - ) - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 0) - ]), - target: target - ) + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let documentID = DOM.Node.ID("document") + let childID = DOM.Node.ID("child") + + try await enqueueStartupReplies( + on: runtime.wire, + 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 } + + try await runtime.wire.emitRaw( + .attributeModified(DOM.Node.ID("unmaterialized"), name: "class", value: "x"), + target: target + ) + try await runtime.wire.emitRaw( + .setChildNodes(parent: documentID, nodes: [ + DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 0) + ]), + target: target + ) - let child = try await waitForChild(in: context) - #expect(child.id == DOMNode.ID(childID)) - #expect(context.state == .attached) + let child = try await waitForChild(in: context) + #expect(child.id == DOMNode.ID(childID)) + #expect(context.state == .attached) + } } @MainActor @Test func startupEnablesTrackedDomainsBeforeInitialDocumentSnapshot() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() - await enqueueStartupReplies(on: runtime.backend) + try await enqueueStartupReplies(on: runtime.wire) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { context.state == .attached } - let commands = await runtime.backend.recordedCommands() - #expect(commands.prefix(startupCommands.count) == startupCommands[...]) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method).prefix(startupCommands.count) == startupCommands[...]) + } } @MainActor @Test func networkEnableFailureFailsStartupBeforeDocumentFetch() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - 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: "Runtime", method: "disable") - await runtime.backend.enqueue((), for: "Inspector", method: "disable") + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Runtime.enable") + await runtime.wire.fail("Network.enable", message: "Network enable failed.") + await runtime.wire.respond(to: "Runtime.disable") + await runtime.wire.respond(to: "Inspector.disable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + guard case .failed = context.state else { + return false + } + return true + } - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false + guard case let .failed(error) = context.state else { + Issue.record("Expected failed context state.") + return + } + guard case .commandFailed(domain: "Network", method: "enable", message: _) = error else { + Issue.record("Expected Network.enable command failure.") + return } - return true - } - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return - } - guard case .commandFailed(domain: "Network", method: "enable", message: _) = error else { - Issue.record("Expected Network.enable command failure.") - return + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Network.enable", + "Runtime.disable", + "Inspector.disable", + ]) + #expect(context.rootNode == nil) } - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Network", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ]) - #expect(context.rootNode == nil) } @MainActor @Test func consoleEnableFailureFailsStartupBeforeAttachingDocument() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - 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: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document"), - for: "DOM", - method: "getDocument" - ) - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - await runtime.backend.enqueue((), for: "Network", method: "disable") - await runtime.backend.enqueue((), for: "Inspector", method: "disable") + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Runtime.enable") + await runtime.wire.respond(to: "Network.enable") + await runtime.wire.respond( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") + ) + ) + await runtime.wire.fail("Console.enable", message: "Console enable failed.") + await runtime.wire.respond(to: "Runtime.disable") + await runtime.wire.respond(to: "Network.disable") + await runtime.wire.respond(to: "Inspector.disable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + guard case .failed = context.state else { + return false + } + return true + } - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false + guard case let .failed(error) = context.state else { + Issue.record("Expected failed context state.") + return + } + guard case .commandFailed(domain: "Console", method: "enable", message: _) = error else { + Issue.record("Expected Console.enable command failure.") + return } - return true - } - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return - } - guard case .commandFailed(domain: "Console", method: "enable", message: _) = error else { - Issue.record("Expected Console.enable command failure.") - return + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Network.enable", + "DOM.getDocument", + "Console.enable", + "Runtime.disable", + "Network.disable", + "Inspector.disable", + ]) + #expect(context.rootNode == nil) } - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - 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"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Network", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ]) - #expect(context.rootNode == nil) } @MainActor @Test func runtimeEnableFailureFailsStartupBeforeConsoleNetworkAndDocumentFetch() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.fail("Runtime.enable", message: "Runtime enable failed.") + await runtime.wire.respond(to: "Inspector.disable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + guard case .failed = context.state else { + return false + } + return true + } - await runtime.backend.enqueue((), for: "Inspector", method: "enable") - await runtime.backend.enqueue((), for: "Inspector", method: "initialized") - await runtime.backend.enqueue((), for: "Inspector", method: "disable") + guard case let .failed(error) = context.state else { + Issue.record("Expected failed context state.") + return + } + guard case .commandFailed(domain: "Runtime", method: "enable", message: _) = error else { + Issue.record("Expected Runtime.enable command failure.") + return + } - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false - } - return true - } - - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Inspector.disable", + ]) + #expect(context.rootNode == nil) } - guard case .commandFailed(domain: "Runtime", method: "enable", message: _) = error else { - Issue.record("Expected Runtime.enable command failure.") - return - } - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ]) - #expect(context.rootNode == nil) } @MainActor @Test func closeAfterAttachedDisablesEnabledDomains() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() - await enqueueStartupReplies(on: runtime.backend) - await enqueueDomainDisableReplies(on: runtime.backend) + try await enqueueStartupReplies(on: runtime.wire) + await enqueueDomainDisableReplies(on: runtime.wire) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { context.state == .attached } - await container.close() + await container.close() - let commands = await runtime.backend.recordedCommands() - #expect(commands == startupCommands + shutdownCommands) - #expect(context.state == .detached) - #expect(context.teardownError == nil) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == startupCommands + shutdownCommands) + #expect(context.state == .detached) + #expect(context.teardownError == nil) + } } @MainActor @Test func closeAfterAttachedClearsAttachmentBackedModels() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let requestID = Network.Request.ID("request-1") - let runtimeContextID = Runtime.ExecutionContext.ID("main") - let networkResults: WebInspectorFetchedResults - let consoleResults: WebInspectorFetchedResults + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let documentID = DOM.Node.ID("document") + let requestID = Network.Request.ID("request-1") + let runtimeContextID = Runtime.ExecutionContext.ID("main") + let networkResults: WebInspectorFetchedResults + let consoleResults: WebInspectorFetchedResults + + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) - 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 } - 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 } + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), + resourceType: .script, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) + try await runtime.wire.emitRaw( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "warning"), + text: "hello", + timestamp: 2 + )), + target: target + ) + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext(id: runtimeContextID, name: "Main", kind: .normal)), + target: target + ) + try await waitUntil { + networkResults.items.count == 1 + && consoleResults.items.count == 1 + && context.executionContexts.count == 1 + } - 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 - ) - 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) + 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.wire) + 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 } - - await container.close() - - #expect(context.state == .detached) - guard case .commandFailed(domain: "Network", method: "disable", message: _) = context.teardownError else { - Issue.record("Expected Network.disable teardown error.") - return + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + + try await enqueueStartupReplies(on: runtime.wire) + await runtime.wire.respond(to: "Console.disable") + await runtime.wire.respond(to: "Runtime.disable") + await runtime.wire.fail("Network.disable", message: "Network disable failed.") + await runtime.wire.respond(to: "Inspector.disable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { context.state == .attached } + + await container.close() + + #expect(context.state == .detached) + guard case .commandFailed(domain: "Network", method: "disable", message: _) = context.teardownError else { + Issue.record("Expected Network.disable teardown error.") + return + } } } @MainActor @Test func restartDisablesPreviousDomainTrackingBeforeReenable() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() + try await withDataKitTestRuntime { runtime in + 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") - ) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("document-1"), 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?.id == DOMNode.ID(DOM.Node.ID("document-1")) } + 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")) } - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("document-2"), nodeType: 9, nodeName: "#document") - ) + await enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("document-2"), nodeType: 9, nodeName: "#document") + ) - context.start() + context.start() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("document-2")) } + try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("document-2")) } - let commands = await runtime.backend.recordedCommands() - #expect(commands == startupCommands + shutdownCommands + startupCommands) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == startupCommands + shutdownCommands + startupCommands) + } } @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"), - ] - } + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let enableGate = await runtime.wire.deferReply(to: "Network.enable") + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Runtime.enable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Network.enable", + ] + } - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) + await enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") + ) - context.start() + context.start() - await enableGate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } + 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) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == Array(startupCommands.prefix(4)) + [ + "Runtime.disable", + "Network.disable", + "Inspector.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) + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let contextID = Runtime.ExecutionContext.ID("main") + + let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Network.enable") + await runtime.wire.respond( + to: "DOM.getDocument", + with: try emptyDocumentResult() + ) + await runtime.wire.respond(to: "Console.enable") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable" + ] + } - 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") - ] - } + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext(id: contextID, name: "Main", kind: .normal)), + target: target + ) + try await waitUntil { + context.executionContexts.first?.id == RuntimeContext.ID(contextID) + } - 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) + enableGate.open() + try await waitUntil { context.state == .attached } + #expect(context.selectedContext?.id == RuntimeContext.ID(contextID)) } - - await enableGate.open() - try await waitUntil { context.state == .attached } - #expect(context.selectedContext?.id == RuntimeContext.ID(contextID)) } @MainActor @Test func consoleEnableReplayIsCapturedBeforeCommandReturns() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = WebInspectorTestGate() - - await runtime.backend.hold(domain: "Console", method: "enable", gate: enableGate) - await enqueueStartupReplies(on: runtime.backend) + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let enableGate = await runtime.wire.deferReply(to: "Console.enable") + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Runtime.enable") + await runtime.wire.respond(to: "Network.enable") + await runtime.wire.respond( + to: "DOM.getDocument", + with: try emptyDocumentResult() + ) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands - } + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + let results: WebInspectorFetchedResults = context.fetchedResults() + try await waitUntil { + runtime.wire.observations.commandMethods == startupCommands + } - 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"] } + try await runtime.wire.emitRaw( + .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"]) + 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" - ) - await runtime.backend.enqueue( - DOM.Node(id: freshDocumentID, nodeType: 9, nodeName: "#document"), - for: "DOM", - method: "getDocument" - ) - 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 - } + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let staleDocumentID = DOM.Node.ID("stale-startup-document") + let freshDocumentID = DOM.Node.ID("fresh-startup-document") + + let consoleGate = await runtime.wire.deferReply(to: "Console.enable") + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + await runtime.wire.respond(to: "Runtime.enable") + await runtime.wire.respond(to: "Network.enable") + await runtime.wire.respond( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: staleDocumentID, nodeType: 9, nodeName: "#document") + ) + ) + await runtime.wire.respond( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: freshDocumentID, nodeType: 9, nodeName: "#document") + ) + ) + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitUntil { + runtime.wire.observations.commandMethods == 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 runtime.wire.emitRaw( + .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 + ) + 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") - ]) + try await waitUntil { context.rootNode?.id == DOMNode.ID(freshDocumentID) } + #expect(context.state == .attached) + #expect(context.node(for: DOMNode.ID(staleDocumentID)) == nil) + #expect(runtime.wire.observations.commandMethods == startupCommands + [ + "DOM.getDocument" + ]) + } } @MainActor @@ -1473,24 +1521,35 @@ func transportBackedFrameRuntimeAndConsoleEventsKeepTargetScope() async throws { @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") + try await withDataKitTestRuntime { runtime in + let (_, context) = try await startContext(runtime: runtime) + _ = try await createFrameTarget( + in: runtime, + id: "console-frame-a", + frameID: "console-frame-a" + ) + _ = try await createFrameTarget( + in: runtime, + id: "console-frame-b", + frameID: "console-frame-b" + ) + await runtime.wire.respond(to: "Runtime.releaseObjectGroup") + await runtime.wire.respond(to: "Runtime.releaseObjectGroup") - await context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-a") - ) - await context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-b") - ) + await context.apply( + Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: WebInspectorTarget.ID("console-frame-a") + ) + await context.apply( + Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: WebInspectorTarget.ID("console-frame-b") + ) - try await waitUntil { - await runtime.backend.recordedCommands().filter { - $0 == RecordedCommand(domain: "Runtime", method: "releaseObjectGroup") - }.count == 2 + try await waitUntil { + runtime.wire.observations.commands.filter { + $0.method == "Runtime.releaseObjectGroup" + }.count == 2 + } } } @@ -1551,44 +1610,45 @@ func transportBackedStyleSheetTextEditRoutesToFrameTargetAndMarksUndo() async th @MainActor @Test func currentPageCommitClearsOldLifecycleStateBeforeAcceptingNewPageEvents() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (_, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let concreteResults = try await context.consoleMessages() - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "old page" - )), - targetID: .currentPage - ) - try await waitUntil { results.items.map(\.text) == ["old page"] } - #expect(concreteResults.items.map(\.text) == ["old page"]) - - context.apply(.didCommitProvisionalTarget(WebInspectorTargetCommitLifecycle( - oldTargetID: .currentPage, - newTarget: WebInspectorLifecycleTarget( - id: .currentPage, - kind: .page, - frameID: FrameID("committed-main-frame"), - isProvisional: false, - pageBindingID: "committed-page" + try await withDataKitTestRuntime { runtime in + let (_, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let concreteResults = try await context.consoleMessages() + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "old page" + )), + targetID: .currentPage ) - ))) - - #expect(results.items.isEmpty) - #expect(concreteResults.items.isEmpty) - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "new page" - )), - targetID: .currentPage - ) - #expect(results.items.map(\.text) == ["new page"]) - #expect(concreteResults.items.map(\.text) == ["new page"]) + try await waitUntil { results.items.map(\.text) == ["old page"] } + #expect(concreteResults.items.map(\.text) == ["old page"]) + + context.apply(.didCommitProvisionalTarget(WebInspectorTargetCommitLifecycle( + oldTargetID: .currentPage, + newTarget: WebInspectorLifecycleTarget( + id: .currentPage, + kind: .page, + frameID: FrameID("committed-main-frame"), + isProvisional: false, + pageBindingID: "committed-page" + ) + ))) + + #expect(results.items.isEmpty) + #expect(concreteResults.items.isEmpty) + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "new page" + )), + targetID: .currentPage + ) + #expect(results.items.map(\.text) == ["new page"]) + #expect(concreteResults.items.map(\.text) == ["new page"]) + } } @MainActor @@ -1758,7 +1818,9 @@ func currentPageCommitRetargetsDataKitStateToNewTransportTarget() async throws { 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 concreteResults = try await context.consoleMessages() - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "destroyed page" - )), - targetID: .currentPage - ) - #expect(legacyResults.items.map(\.text) == ["destroyed page"]) - #expect(concreteResults.items.map(\.text) == ["destroyed page"]) + try await withDataKitTestRuntime { runtime in + let (_, context) = try await startContext(runtime: runtime) + let oldRoot = context.rootNode + let legacyResults: WebInspectorFetchedResults = context.fetchedResults() + let concreteResults = try await context.consoleMessages() + await context.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "destroyed page" + )), + targetID: .currentPage + ) + #expect(legacyResults.items.map(\.text) == ["destroyed page"]) + #expect(concreteResults.items.map(\.text) == ["destroyed page"]) - context.apply(.targetDestroyed(targetID: .currentPage)) + context.apply(.targetDestroyed(targetID: .currentPage)) - #expect(context.rootNode === oldRoot) - #expect(legacyResults.items.isEmpty) - #expect(concreteResults.items.isEmpty) + #expect(context.rootNode === oldRoot) + #expect(legacyResults.items.isEmpty) + #expect(concreteResults.items.isEmpty) + } } @MainActor @@ -1954,35 +2017,36 @@ func currentPageTargetDestroyedDuringRetargetDoesNotDetachOrClearNetwork() async @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)] - } - let concreteNetworkResults = try await context.networkRequests() - #expect(concreteNetworkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)]) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let networkResults: WebInspectorFetchedResults = context.fetchedResults() + let staleRequestID = Network.Request.ID("stale-before-restart") + + try await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.wire) + try await waitUntil { + networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] + } + let concreteNetworkResults = try await context.networkRequests() + #expect(concreteNetworkResults.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") - ) + await enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("fresh-after-restart"), nodeType: 9, nodeName: "#document") + ) - context.start() + context.start() - #expect(networkResults.items.isEmpty) - #expect(concreteNetworkResults.items.isEmpty) - #expect(context.registeredRequest(for: NetworkRequest.ID(staleRequestID)) == nil) - 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) + #expect(concreteNetworkResults.items.isEmpty) + #expect(context.registeredRequest(for: NetworkRequest.ID(staleRequestID)) == nil) + 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) } - #expect(networkResults.items.isEmpty) } @MainActor @@ -2260,811 +2324,822 @@ func mainFrameNavigatedReloadsDOMAndClearsRuntimeContexts() async throws { @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() + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let staleID = Runtime.ExecutionContext.ID("stale") + let replayID = Runtime.ExecutionContext.ID("replayed") + let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") + + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext(id: staleID, name: "Stale", kind: .normal)), + target: target + ) + try await waitUntil { + context.executionContexts.first?.id == RuntimeContext.ID(staleID) + } - 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 enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") + ) - 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 { + runtime.wire.observations.commandMethods == startupCommands + shutdownCommands + [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + ] + } + #expect(context.executionContexts.isEmpty) + #expect(context.selectedContext == nil) - 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) + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext(id: replayID, name: "Replayed", kind: .normal)), + target: target + ) + try await waitUntil { + context.executionContexts.map(\.id) == [RuntimeContext.ID(replayID)] + } - 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)] + enableGate.open() + try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } + #expect(context.selectedContext?.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() + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let enableGate = await runtime.wire.deferReply(to: "Console.enable") + + try await runtime.wire.emitRaw( + .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.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 enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") + ) - 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 { + runtime.wire.observations.commandMethods == startupCommands + shutdownCommands + startupCommands + } + #expect(results.items.isEmpty) + + try await runtime.wire.emitRaw( + .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"] } - context.start() - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands + shutdownCommands + startupCommands + enableGate.open() + try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } + #expect(results.items.count == 1) } - #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.setDOMAttribute( - "data-before-reset", - value: "1", - on: document.id, - options: .automatic - ) - 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" - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + await runtime.wire.respond(to: "DOM.setAttributeValue") + await runtime.wire.respond(to: "DOM.markUndoableState") + try await context.setDOMAttribute( + "data-before-reset", + value: "1", + on: document.id, + options: .automatic + ) + 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.wire.respond( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: replacementID, nodeType: 9, nodeName: "#document") + ) + ) - await runtime.backend.emit(.documentUpdated, target: target) + try await runtime.wire.emitRaw(.documentUpdated, target: target) - try await waitUntil { - context.rootNode?.id == DOMNode.ID(replacementID) - } - try await waitUntil { - recorder.updates.contains { update in + 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() } - } - 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 + await runtime.wire.respond(to: "DOM.undo") + await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { + try await context.undoDOMChange() } - 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.undoDOMChange() - } - let commands = await runtime.backend.recordedCommands() - #expect(!commands.contains { $0.domain == "DOM" && $0.method == "undo" }) + let commands = runtime.wire.observations.commands + #expect(!commands.contains { $0.method == "DOM.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 withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let insertedID = DOM.Node.ID("inserted-child") + + try await runtime.wire.emitRaw( + .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 + 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) } - #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) + try await withDataKitTestRuntime { runtime in + 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") + try await runtime.wire.emitRaw( + .setChildNodes(parent: document.id.proxyID, nodes: [ + DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div") + ]), + target: target + ) - #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 + 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) } - #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 - ) +@MainActor +@Test +func domTreeControllerPrunesRetainedChildDescendantsOnReplacement() async throws { + try await withDataKitTestRuntime { runtime in + 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") + try await runtime.wire.emitRaw( + .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) - guard case let .delta(childrenChanged) = recorder.updates.last else { - Issue.record("Expected DOM children replacement delta.") - return + 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))) + + try await runtime.wire.emitRaw( + .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) } - #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") - ] + try await withDataKitTestRuntime { runtime in + 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") + try await runtime.wire.emitRaw( + .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 - ) + ]), + target: target + ) - try await recorder.waitForUpdateCount(2) - guard case let .delta(childrenChanged) = recorder.updates.last else { - Issue.record("Expected associated subtree replacement delta.") - return + 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)) } - #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 + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext( + runtime: runtime, + document: DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + childNodeCount: 1 ) - ), - target: target - ) + ) + 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") + + try await runtime.wire.emitRaw( + .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))) + + try await runtime.wire.emitRaw( + .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(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)) + 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)] + ))) + + try await runtime.wire.emitRaw( + .pseudoElementAdded( + parent: hostID, + element: DOM.Node( + id: beforePseudoID, + nodeType: 1, + nodeName: "::before", + pseudoType: .before + ) + ), + target: target + ) - await runtime.backend.emit( - .pseudoElementRemoved(parent: hostID, element: beforePseudoID), - 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)) + + try await runtime.wire.emitRaw( + .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)] - ) + 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 runtime.wire.emitRaw( + .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 == []) + 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) + try await withDataKitTestRuntime { runtime in + 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") + + try await runtime.wire.emitRaw( + .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( - .setChildNodes(parent: elementID, nodes: [ - DOM.Node(id: textID, nodeType: 3, nodeName: "#text", nodeValue: "old") - ]), - target: target - ) - try await recorder.waitForUpdateCount(5) + try await runtime.wire.emitRaw( + .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( - .characterDataModified(textID, value: "new"), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(textID))?.nodeValue == "new" } - try await recorder.waitForUpdateCount(6) + try await runtime.wire.emitRaw( + .childNodeCountUpdated(elementID, count: 1), + target: target + ) + try await waitUntil { context.node(for: DOMNode.ID(elementID))?.childNodeCount == 1 } + try await recorder.waitForUpdateCount(4) + + try await runtime.wire.emitRaw( + .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( - .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) + try await runtime.wire.emitRaw( + .characterDataModified(textID, value: "new"), + target: target + ) + try await waitUntil { context.node(for: DOMNode.ID(textID))?.nodeValue == "new" } + try await recorder.waitForUpdateCount(6) + + try await runtime.wire.emitRaw( + .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) + try await runtime.wire.emitRaw( + .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))), - ]) + 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", + try await withDataKitTestRuntime { runtime in + 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: 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", + DOM.Node(id: ignoredIframeChildID, nodeType: 1, nodeName: "SPAN", localName: "span") + ], + contentDocument: DOM.Node( + id: frameDocumentID, + nodeType: 9, + nodeName: "#document", children: [ - DOM.Node(id: shadowSpanID, nodeType: 1, nodeName: "SPAN", localName: "span") - ], - shadowRootType: .open + DOM.Node(id: frameBodyID, nodeType: 1, nodeName: "BODY", localName: "body") + ] ) - ], - beforePseudoElement: DOM.Node( - id: beforePseudoID, + ), + DOM.Node( + id: templateHostID, nodeType: 1, - nodeName: "::before", - pseudoType: .before + nodeName: "TEMPLATE", + localName: "template", + templateContent: DOM.Node( + id: templateContentID, + nodeType: 11, + nodeName: "#document-fragment" + ) ), - afterPseudoElement: DOM.Node( - id: afterPseudoID, + DOM.Node( + id: shadowHostID, nodeType: 1, - nodeName: "::after", - pseudoType: .after - ) - ), - ] + 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) + 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) + try await withDataKitTestRuntime { runtime in + 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") - #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]) + try await runtime.wire.emitRaw( + .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 + ) - await enqueueCSSStyleReplies(on: runtime.backend) - try context.selectNode(child.id, reveal: .selectOnly) + 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]) + + try await enqueueCSSStyleReplies(on: runtime.wire) + try context.selectNode(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 + )) - 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) - 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 await recorder.waitForUpdateCount(3) - guard case let .delta(selectionCleared) = recorder.updates.last else { - Issue.record("Expected DOM selection clear delta.") - return + try context.selectNode(document.id, reveal: .none) + try await recorder.waitForUpdateCount(4) + #expect(revealRecorder.requests.count == 1) } - #expect(selectionCleared == .selectionChanged(nodeID: nil)) - #expect(controller.snapshot.selectedNodeID == nil) - - try context.selectNode(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 } + try await withDataKitTestRuntime { runtime in + 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") - 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) + try await runtime.wire.emitRaw( + .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 } - context.select(selected) - try await recorder.waitForUpdateCount(2) - #expect(controller.snapshot.selectedNodeID == selected.id) + 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) - await runtime.backend.emit( - .setChildNodes(parent: parentID, nodes: [ - DOM.Node(id: replacementID, nodeType: 3, nodeName: "#text", nodeValue: "replacement") - ]), - target: target - ) + context.select(selected) + try await recorder.waitForUpdateCount(2) + #expect(controller.snapshot.selectedNodeID == selected.id) + + try await runtime.wire.emitRaw( + .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))) + 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) } - #expect(controller.snapshot.selectedNodeID == nil) } @@ -3423,279 +3498,281 @@ func stalledFetchedResultsSubscriberDoesNotReconfigureRemovedItems() async throw @MainActor @Test func fetchedResultsPublishNetworkTopologyAndPropertyUpdates() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) + defer { recorder.cancel() } + try await recorder.waitUntilStarted() + + let requestID = Network.Request.ID("controller-request") + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/first", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) - 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(recorder.reconfigureItemIDSets.last == []) + #expect(results.snapshot.itemIDs == [modelID]) + let request = try #require(results.items.first) + + try await runtime.wire.emitRaw( + .responseReceived( + id: requestID, + response: Network.Response(status: 200, statusText: "OK", mimeType: "text/plain"), + resourceType: .fetch, + timestamp: 2 + ), + 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(recorder.reconfigureItemIDSets.last == []) - #expect(results.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 } + try await recorder.waitForTransactionCount(2) + #expect(results.items.first === request) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) - try await waitUntil { request.status == 200 } - try await recorder.waitForTransactionCount(2) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), + target: target + ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), - target: target - ) + try await waitUntil { request.decodedDataLength == 7 && request.encodedDataLength == 3 } + try await recorder.waitForTransactionCount(3) + #expect(results.items.first === request) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) - try await waitUntil { request.decodedDataLength == 7 && request.encodedDataLength == 3 } - try await recorder.waitForTransactionCount(3) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) + try await runtime.wire.emitRaw( + .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), + target: target + ) - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), - target: target - ) + try await waitUntil { request.state == .finished } + try await recorder.waitForTransactionCount(4) + #expect(results.items.first === request) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) - try await waitUntil { request.state == .finished } - try await recorder.waitForTransactionCount(4) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - 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 } - try await recorder.waitForTransactionCount(5) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - 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 runtime.wire.emitRaw( + .requestServedFromMemoryCache( + id: requestID, + response: Network.Response(url: "https://example.com/first", status: 200), + resourceType: nil, + timestamp: 5 + ), + target: target + ) - try await recorder.waitForTransactionCount(6) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: failedModelID, indexPath: failedIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == []) - let failedRequest = try #require(results.items.last) + try await waitUntil { request.finishedOrFailedTimestamp == 5 } + try await recorder.waitForTransactionCount(5) + #expect(results.items.first === request) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [modelID]) + + let failedRequestID = Network.Request.ID("controller-failed-request") + let failedModelID = NetworkRequest.ID(failedRequestID) + let failedIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 1) + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: failedRequestID, + request: Network.Request(id: failedRequestID, url: "https://example.com/failed", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 6 + ), + target: target + ) - await runtime.backend.emit( - .loadingFailed(id: failedRequestID, errorText: "cancelled", canceled: true, timestamp: 7), - target: target - ) + try await recorder.waitForTransactionCount(6) + #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: failedModelID, indexPath: failedIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == []) + let failedRequest = try #require(results.items.last) - try await waitUntil { - if case .failed(errorText: "cancelled", canceled: true) = failedRequest.state { - return true - } - return false - } - try await recorder.waitForTransactionCount(7) - #expect(results.items.last === failedRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: failedModelID, indexPath: failedIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [failedModelID]) + try await runtime.wire.emitRaw( + .loadingFailed(id: failedRequestID, errorText: "cancelled", canceled: true, timestamp: 7), + target: target + ) - 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 waitUntil { + if case .failed(errorText: "cancelled", canceled: true) = failedRequest.state { + return true + } + return false + } + try await recorder.waitForTransactionCount(7) + #expect(results.items.last === failedRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: failedModelID, indexPath: failedIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [failedModelID]) + + let socketRequestID = Network.Request.ID("controller-socket-request") + let socketModelID = NetworkRequest.ID(socketRequestID) + let socketIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 2) + try await runtime.wire.emitRaw( + .webSocket(.created(id: socketRequestID, url: "wss://example.com/socket")), + target: target + ) - try await recorder.waitForTransactionCount(8) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == []) - let socketRequest = try #require(results.items.last) + try await recorder.waitForTransactionCount(8) + #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == []) + let socketRequest = try #require(results.items.last) - await runtime.backend.emit( - .webSocket(.handshakeRequest( - id: socketRequestID, - request: Network.Request( + try await runtime.wire.emitRaw( + .webSocket(.handshakeRequest( id: socketRequestID, - url: "wss://example.com/socket", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - timestamp: 8 - )), - target: target - ) + 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" } - try await recorder.waitForTransactionCount(9) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.webSocket?.handshakeRequest?.headers["Upgrade"] == "websocket" } + try await recorder.waitForTransactionCount(9) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - await runtime.backend.emit( - .webSocket(.handshakeResponse( - id: socketRequestID, - response: Network.Response(status: 101, statusText: "Switching Protocols"), - timestamp: 9 - )), - target: target - ) + try await runtime.wire.emitRaw( + .webSocket(.handshakeResponse( + id: socketRequestID, + response: Network.Response(status: 101, statusText: "Switching Protocols"), + timestamp: 9 + )), + target: target + ) - try await waitUntil { socketRequest.status == 101 } - try await recorder.waitForTransactionCount(10) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.status == 101 } + try await recorder.waitForTransactionCount(10) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - 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 runtime.wire.emitRaw( + .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 } - try await recorder.waitForTransactionCount(11) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.webSocket?.frames.count == 1 } + try await recorder.waitForTransactionCount(11) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - 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 runtime.wire.emitRaw( + .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 } - try await recorder.waitForTransactionCount(12) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.webSocket?.frames.count == 2 } + try await recorder.waitForTransactionCount(12) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - await runtime.backend.emit( - .webSocket(.error(id: socketRequestID, message: "decode failed", timestamp: 12)), - target: target - ) + try await runtime.wire.emitRaw( + .webSocket(.error(id: socketRequestID, message: "decode failed", timestamp: 12)), + target: target + ) - try await waitUntil { socketRequest.webSocket?.frames.count == 3 } - try await recorder.waitForTransactionCount(13) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.webSocket?.frames.count == 3 } + try await recorder.waitForTransactionCount(13) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - await runtime.backend.emit( - .webSocket(.closed(id: socketRequestID, timestamp: 13)), - target: target - ) + try await runtime.wire.emitRaw( + .webSocket(.closed(id: socketRequestID, timestamp: 13)), + target: target + ) - try await waitUntil { socketRequest.state == .finished && socketRequest.webSocket?.readyState == .closed } - try await recorder.waitForTransactionCount(14) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + try await waitUntil { socketRequest.state == .finished && socketRequest.webSocket?.readyState == .closed } + try await recorder.waitForTransactionCount(14) + #expect(results.items.last === socketRequest) + #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) + #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) + } } @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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.mimeType) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) + defer { recorder.cancel() } + try await recorder.waitUntilStarted() + + let requestID = Network.Request.ID("sectioned-controller-request") + let modelID = NetworkRequest.ID(requestID) + try await runtime.wire.emitRaw( + .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 + ) - 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(results.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) + #expect(results.snapshot.itemIDs == [modelID]) - try await recorder.waitForTransactionCount(1) - #expect(results.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) - #expect(results.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" + try await runtime.wire.emitRaw( + .responseReceived( + id: requestID, + response: Network.Response( + url: "https://media.example.com/clip.mp4", + status: 200, + mimeType: "video/mp4" + ), + resourceType: .fetch, + timestamp: 2 ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) + 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 == []) + 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 @@ -3898,36 +3975,37 @@ func clearNetworkRequestsResetsDescriptorBackedQueryState() async throws { @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") + try await withDataKitTestRuntime { runtime in + 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)] - } + try await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.wire) + 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") - ) + await enqueueDomainDisableReplies(on: runtime.wire) + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: DOM.Node.ID("fresh-query-root"), nodeType: 9, nodeName: "#document") + ) - context.start() + context.start() - try await waitUntil { - networkResults.items.isEmpty && context.state == .attached - } + 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)] + let freshRequestID = Network.Request.ID("fresh-query-after-restart") + try await emitFinishedRequest(id: freshRequestID, target: target, backend: runtime.wire) + try await waitUntil { + networkResults.items.map(\.id) == [NetworkRequest.ID(freshRequestID)] + } } } @@ -4161,358 +4239,368 @@ func networkRequestResourceCategoryUsesResponseHeadersWithoutPendingURLInference @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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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(results.snapshot.itemIDs == [firstModelID, secondModelID]) - - await 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(results.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( + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) + defer { recorder.cancel() } + try await recorder.waitUntilStarted() + + let firstRequestID = Network.Request.ID("clear-first-request") + let firstModelID = NetworkRequest.ID(firstRequestID) + try await runtime.wire.emitRaw( + .requestWillBeSent( id: firstRequestID, - url: "https://example.com/redirected", - method: "GET" + request: Network.Request(id: firstRequestID, url: "https://example.com/first", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 ), - 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) + target: target + ) + try await recorder.waitForTransactionCount(1) + + let secondRequestID = Network.Request.ID("clear-second-request") + let secondModelID = NetworkRequest.ID(secondRequestID) + try await runtime.wire.emitRaw( + .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(results.snapshot.itemIDs == [firstModelID, secondModelID]) + + await 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(results.snapshot.itemIDs == []) + #expect(results.items.isEmpty) + #expect(context.registeredRequest(for: firstModelID) == nil) + #expect(context.registeredRequest(for: secondModelID) == nil) + + let clearedEventBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw( + .responseReceived( + id: firstRequestID, + response: Network.Response(status: 200), + resourceType: .fetch, + timestamp: 3 + ), + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: firstRequestID, dataLength: 7, encodedDataLength: 4, timestamp: 4), + target: target + ) + try await runtime.wire.emitRaw( + .loadingFinished(id: secondRequestID, timestamp: 5, sourceMapURL: nil, metrics: nil), + target: target + ) + try await runtime.wire.emitRaw( + .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 + try await runtime.wire.emitRaw( + .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( + try await runtime.wire.emitRaw( + .requestWillBeSent( id: firstRequestID, - url: "https://example.com/reused", - method: "GET" + request: Network.Request( + id: firstRequestID, + url: "https://example.com/reused", + method: "GET" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 8 ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 8 - ), - target: target - ) + 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) + 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() + try await withDataKitTestRuntime { runtime in + 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( + let apiRequestID = Network.Request.ID("queryable-api-request") + try await runtime.wire.emitRaw( + .requestWillBeSent( id: apiRequestID, - url: "https://api.example.test/graphql?operation=Feed", - method: "POST" + request: Network.Request( + id: apiRequestID, + url: "https://api.example.test/graphql?operation=Feed", + method: "POST" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 ), - 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" + target: target + ) + try await runtime.wire.emitRaw( + .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 ), - 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" + 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") + try await runtime.wire.emitRaw( + .responseReceived( + id: cssRequestID, + response: Network.Response( + url: "https://example.test/app.css", + status: 200, + mimeType: "text/css; charset=utf-8" + ), + resourceType: .other, + timestamp: 3 ), - resourceType: .other, - timestamp: 3 - ), - target: target - ) + 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) + 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 fetchedResultsPublishConsoleInsertUpdateAndDeleteTransactions() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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 withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) + defer { recorder.cancel() } + try await recorder.waitUntilStarted() + + try await runtime.wire.emitRaw( + .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(results.snapshot.itemIDs.first) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0))]) + try await recorder.waitForTransactionCount(1) + let firstID = try #require(results.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 runtime.wire.emitRaw( + .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))]) + 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 runtime.wire.emitRaw( + .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(results.snapshot.itemIDs.last) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1))]) + try await recorder.waitForTransactionCount(3) + let secondID = try #require(results.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 - ) + await runtime.wire.respond(to: "Runtime.releaseObjectGroup") + try await runtime.wire.emitRaw( + .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(results.snapshot.itemIDs == []) + 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(results.snapshot.itemIDs == []) + _ = await runtime.wire.observations.waitForCompletedCommands( + method: "Runtime.releaseObjectGroup", + count: 1 + ) + } } @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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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(results.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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.method) + let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) + defer { recorder.cancel() } + try await recorder.waitUntilStarted() + + let getID = Network.Request.ID("sectioned-get") + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: getID, + request: Network.Request(id: getID, url: "https://example.com/get", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: target ) - ]) - - 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) + try await recorder.waitForTransactionCount(1) + #expect(results.sections.map(\.title) == ["GET"]) + #expect(results.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") + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: postID, + request: Network.Request(id: postID, url: "https://example.com/post", method: "POST"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 2 + ), + target: target ) - ]) - 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) + 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") + try await runtime.wire.emitRaw( + .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 withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) + + try await runtime.wire.emitRaw( + .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") + 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 @@ -4537,1338 +4625,1381 @@ func fetchedResultsTransactionDiffsMovesByItemID() { @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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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) + try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(element) + 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 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"))) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "CSS.enable" } == false) + #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" }) + #expect(commands.contains { $0.method == "CSS.getInlineStylesForNode" }) + #expect(commands.contains { $0.method == "CSS.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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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", + await runtime.wire.fail( + "CSS.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"), - ]) + ) + await runtime.wire.respond(to: "CSS.enable") + try await enqueueCSSStyleReplies(on: runtime.wire) + + 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 = runtime.wire.observations.commands + .filter { $0.method.hasPrefix("CSS.") } + #expect(cssCommands.map(\.method) == [ + "CSS.getMatchedStylesForNode", + "CSS.enable", + "CSS.getMatchedStylesForNode", + "CSS.getInlineStylesForNode", + "CSS.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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let frameTargetRawValue = "frame-css-agent" + _ = try await createFrameTarget( + in: runtime, + id: frameTargetRawValue, + frameID: "frame-css-agent" + ) + let elementID = DOM.Node.ID("frame-styled-node", scopedToTargetRawValue: frameTargetRawValue) - await runtime.backend.enqueueFailure( - WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "getMatchedStylesForNode", + try await runtime.wire.emitRaw( + .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.wire.fail( + "CSS.getMatchedStylesForNode", message: "CSS agent is not enabled." - ), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue((), for: "CSS", method: "enable") - await enqueueCSSStyleReplies(on: runtime.backend) + ) + await runtime.wire.respond(to: "CSS.enable") + try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(element) + context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } + 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" + let enableCommand = runtime.wire.observations.commands.first { + $0.method == "CSS.enable" + } + #expect(enableCommand?.destination == .target(frameTargetRawValue)) } - #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) + try await withDataKitTestRuntime { runtime in + let (_, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) - context.select(document) + 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) + #expect(document.elementStyles == nil) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" } == false) + #expect(commands.contains { $0.method == "CSS.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 { + try await withDataKitTestRuntime { runtime in + 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") + + try await runtime.wire.emitRaw( + .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 { - return false + Issue.record("Expected loaded document children.") + return } - 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 } + let selected = try #require(children.first { $0.id == DOMNode.ID(selectedID) }) - func reloadSelectedStyles() async throws { - await enqueueCSSStyleReplies(on: runtime.backend) + try await enqueueCSSStyleReplies(on: runtime.wire) context.select(selected) + let styles = try #require(selected.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 } + @MainActor + func reloadSelectedStyles() async throws { + try await enqueueCSSStyleReplies(on: runtime.wire) + context.select(selected) + try await waitUntil { styles.phase == .loaded } + } - try await reloadSelectedStyles() + try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) + try await waitUntil { styles.phase == .needsRefresh } - 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() - try await reloadSelectedStyles() + try await runtime.wire.emitRaw( + .styleSheetAdded(CSS.StyleSheetHeader( + styleSheetID: CSS.StyleSheet.ID("sheet-1"), + origin: CSS.Origin(rawValue: "author") + )), + target: target + ) + try await waitUntil { styles.phase == .needsRefresh } - await runtime.backend.emit(.styleSheetRemoved(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } + try await reloadSelectedStyles() - try await reloadSelectedStyles() + try await runtime.wire.emitRaw(.styleSheetRemoved(CSS.StyleSheet.ID("sheet-1")), target: target) + try await waitUntil { styles.phase == .needsRefresh } - await runtime.backend.emit(.mediaQueryResultChanged, target: target) - try await waitUntil { styles.phase == .needsRefresh } + try await reloadSelectedStyles() - try await reloadSelectedStyles() + try await runtime.wire.emitRaw(.mediaQueryResultChanged, target: target) + try await waitUntil { styles.phase == .needsRefresh } - 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) + try await reloadSelectedStyles() - 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) + let otherAttributeBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw(.attributeModified(otherID, name: "class", value: "ignored"), target: target) + let didProcessOtherAttribute = await context.waitForEventPumpAppliedSequenceForTesting( + after: otherAttributeBaseline + ) + #expect(didProcessOtherAttribute) + #expect(styles.phase == .loaded) - await runtime.backend.emit(.nodeLayoutFlagsChanged(selectedID), target: target) - try await waitUntil { styles.phase == .needsRefresh } + let otherLayoutBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw(.nodeLayoutFlagsChanged(otherID), target: target) + let didProcessOtherLayout = await context.waitForEventPumpAppliedSequenceForTesting( + after: otherLayoutBaseline + ) + #expect(didProcessOtherLayout) + #expect(styles.phase == .loaded) - try await reloadSelectedStyles() + try await runtime.wire.emitRaw(.nodeLayoutFlagsChanged(selectedID), target: target) + try await waitUntil { styles.phase == .needsRefresh } - await runtime.backend.emit(.attributeModified(selectedID, name: "class", value: "changed"), target: target) - try await waitUntil { styles.phase == .needsRefresh } + try await reloadSelectedStyles() + + try await runtime.wire.emitRaw(.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" - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + let computedGate = await runtime.wire.deferReply( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([ + CSS.ComputedProperty(name: "display", value: "grid"), + ]) + ) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { - await runtime.backend.recordedCommands().contains( - RecordedCommand(domain: "CSS", method: "getComputedStyleForNode") + try await runtime.wire.emitRaw( + .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.wire.respond( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(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")) + ) + ])) + ) + await runtime.wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(CSS.InlineStyles()) + ) + + context.select(element) + let styles = try #require(element.elementStyles) + try await waitUntil { + runtime.wire.observations.commandMethods.contains( + "CSS.getComputedStyleForNode" + ) + } - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } + try await runtime.wire.emitRaw(.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) + computedGate.open() + _ = await runtime.wire.observations.waitForCompletedCommands( + method: "CSS.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" - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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.wire.respond( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(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;") + ) + ])) + ) + await runtime.wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(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") + ] + ) + )) + ) + await runtime.wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([ + CSS.ComputedProperty(name: "display", value: "grid"), + ]) + ) - context.select(element) + 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]) + 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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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.setStyleHydrationActive(true) - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } + context.setStyleHydrationActive(true) + try await enqueueCSSStyleReplies(on: runtime.wire) + 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 enqueueCSSStyleReplies(on: runtime.wire) + try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { - await matchedStylesCommandCount(on: runtime.backend) == 2 + try await waitUntil { + matchedStylesCommandCount(on: runtime.wire) == 2 + } + try await waitUntil { styles.phase == .loaded } } - 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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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 } + try await enqueueCSSStyleReplies(on: runtime.wire) + 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) + try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) + try await waitUntil { styles.phase == .needsRefresh } + #expect(styles.phase == .needsRefresh) + #expect(matchedStylesCommandCount(on: runtime.wire) == 1) - await enqueueCSSStyleReplies(on: runtime.backend) - context.setStyleHydrationActive(true) + try await enqueueCSSStyleReplies(on: runtime.wire) + context.setStyleHydrationActive(true) - try await waitUntil { styles.phase == .loaded } - #expect(await matchedStylesCommandCount(on: runtime.backend) == 2) + try await waitUntil { styles.phase == .loaded } + #expect(matchedStylesCommandCount(on: runtime.wire) == 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.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.requestSetCSSProperty( - propertyID, - enabled: false, - options: .automatic - )) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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) - try await waitUntil { - await matchedStylesCommandCount(on: runtime.backend) == 2 - } - try await waitUntil { styles.phase == .loaded } + context.setStyleHydrationActive(true) + try await enqueueCSSStyleReplies(on: runtime.wire) + context.select(element) + let styles = try #require(element.elementStyles) + 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 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.wire.respond( + to: "CSS.setStyleText", + with: try rawCSSStyleResult(disabledStyle) + ) + await runtime.wire.respond( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(CSS.MatchedStyles(matchedRules: [ + CSS.Rule( + selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), + origin: CSS.Origin(rawValue: "regular"), + style: disabledStyle + ) + ])) + ) + await runtime.wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(CSS.InlineStyles()) + ) + await runtime.wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([ + CSS.ComputedProperty(name: "display", value: "grid"), + ]) + ) - let property = try #require(styles.sections.first?.style.properties.first) - #expect(property.status == .disabled) - #expect(property.isModifiedByInspector) + let propertyID = try #require(styles.sections.first?.style.properties.first?.id) + await runtime.wire.respond(to: "DOM.markUndoableState") + #expect(context.requestSetCSSProperty( + propertyID, + enabled: false, + options: .automatic + )) + + try await waitUntil { + matchedStylesCommandCount(on: runtime.wire) == 2 + } + try await waitUntil { styles.phase == .loaded } + + let commands = runtime.wire.observations.commands + let setStyleText = try #require(commands.last { $0.method == "CSS.setStyleText" }) + #expect(try commandNestedStringParameter( + setStyleText, + object: "styleId", + key: "styleSheetId" + ) == "style-1") + #expect(try commandStringParameter(setStyleText, "text") == "/* display: grid; */") + let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } + #expect(undoMarks.count == 1) + #expect(undoMarks.first?.destination == .target(wireTargetID(target))) + + 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") + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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) - let propertyID = try #require(styles.sections.first?.style.properties.first?.id) - try await context.setCSSDeclarationText( - "display: flex;", - for: propertyID, - options: .automatic - ) + try await enqueueCSSStyleReplies(on: runtime.wire) + context.select(element) + let styles = try #require(element.elementStyles) + 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: flex;") - let undoMarks = commands.filter { $0.domain == "DOM" && $0.method == "markUndoableState" } - #expect(undoMarks.count == 1) - #expect(undoMarks.first?.targetID == target.id) + 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.wire.respond( + to: "CSS.setStyleText", + with: try rawCSSStyleResult(updatedStyle) + ) + await runtime.wire.respond(to: "DOM.markUndoableState") - 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) + let propertyID = try #require(styles.sections.first?.style.properties.first?.id) + try await context.setCSSDeclarationText( + "display: flex;", + for: propertyID, + options: .automatic + ) + + let commands = runtime.wire.observations.commands + let setStyleText = try #require(commands.last { $0.method == "CSS.setStyleText" }) + #expect(try commandNestedStringParameter( + setStyleText, + object: "styleId", + key: "styleSheetId" + ) == "style-1") + #expect(try commandStringParameter(setStyleText, "text") == "display: flex;") + let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } + #expect(undoMarks.count == 1) + #expect(undoMarks.first?.destination == .target(wireTargetID(target))) + + 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 withDataKitTestRuntime { runtime in + let (_, context) = try await startContext(runtime: runtime) + let frameTarget = try await createFrameTarget(in: runtime) + let proxyRuleID = CSS.Rule.ID( + "frame-sheet\u{1F}3", + scopedToTargetRawValue: frameTarget.id.rawValue + ) + let ruleID = CSSStyleRule.ID(proxyRuleID) - try await context.setCSSRuleSelector( - ".updated", - for: ruleID, - options: .automatic - ) + await runtime.wire.respond( + to: "CSS.setRuleSelector", + with: try rawCSSRuleResult(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-sheet\u{1F}4", + scopedToTargetRawValue: frameTarget.id.rawValue + ), + isEditable: true + ) + )) + ) + await runtime.wire.respond(to: "DOM.markUndoableState") - 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) + try await context.setCSSRuleSelector( + ".updated", + for: ruleID, + options: .automatic + ) - let markUndoableState = try #require(commands.first { $0.domain == "DOM" && $0.method == "markUndoableState" }) - #expect(markUndoableState.targetID == frameTarget.id) - #expect(markUndoableState.route == RoutingTargetID(frameTarget.id.rawValue)) + let commands = runtime.wire.observations.commands + let setRuleSelector = try #require(commands.first { $0.method == "CSS.setRuleSelector" }) + #expect(setRuleSelector.destination == .target(wireTargetID(frameTarget))) + #expect(try commandNestedStringParameter( + setRuleSelector, + object: "ruleId", + key: "styleSheetId" + ) == "frame-sheet") + + let markUndoableState = try #require(commands.first { $0.method == "DOM.markUndoableState" }) + #expect(markUndoableState.destination == .target(wireTargetID(frameTarget))) + } } @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 } + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let document = try #require(context.rootNode) + let elementID = DOM.Node.ID("styled-node") + + try await runtime.wire.emitRaw( + .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.wire.respond( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(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 + ) + ), + ])) + ) + await runtime.wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(CSS.InlineStyles()) + ) + await runtime.wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([ + CSS.ComputedProperty(name: "display", value: "grid"), + ]) + ) + 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.requestSetCSSProperty( - userAgentPropertyID, - enabled: false, - options: .automatic - ) == false) + 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.requestSetCSSProperty( + userAgentPropertyID, + enabled: false, + options: .automatic + ) == false) - let editableSection = try #require(styles.sections.first { $0.title == ".card" }) - let editablePropertyID = try #require(editableSection.style.properties.first?.id) + 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.requestSetCSSProperty( - editablePropertyID, - enabled: false, - options: .automatic - ) == false) + try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) + try await waitUntil { styles.phase == .needsRefresh } + #expect(context.requestSetCSSProperty( + editablePropertyID, + enabled: false, + options: .automatic + ) == false) - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "setStyleText")) == false) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "CSS.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") + try await withDataKitTestRuntime { runtime in + 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") + + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) - 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 } - 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 runtime.wire.emitRaw( + .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 - } + try await waitUntil { + context.node(for: DOMNode.ID(grandchildID)) != nil + } - await runtime.backend.emit( - .childNodeRemoved(parent: documentID, node: childID), - target: target - ) + try await runtime.wire.emitRaw( + .childNodeRemoved(parent: documentID, node: childID), + target: target + ) - try await waitUntil { - context.node(for: DOMNode.ID(childID)) == nil - && context.node(for: DOMNode.ID(grandchildID)) == nil + 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") + try await withDataKitTestRuntime { runtime in + 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") + + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) - 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 } - 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 runtime.wire.emitRaw( + .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))) + 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 runtime.wire.emitRaw( + .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 + 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) } - #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") + try await withDataKitTestRuntime { runtime in + 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") + + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) - 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 } - 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 runtime.wire.emitRaw( + .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))) + 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 runtime.wire.emitRaw( + .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 + 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(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") + try await withDataKitTestRuntime { runtime in + 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") + + try await enqueueStartupReplies( + on: runtime.wire, + document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) - 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 } - 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 runtime.wire.emitRaw( + .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))) + 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 runtime.wire.emitRaw( + .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 + 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 } - return children.first === grandchild + #expect(context.node(for: DOMNode.ID(grandchildID)) === 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) + try await withDataKitTestRuntime { runtime in + let documentID = DOM.Node.ID("document") - 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")) - } + let gate = await runtime.wire.deferReply( + to: "DOM.getDocument", + with: try domDocumentResult( + DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + ) + ) + await enqueueDomainEnableReplies(on: runtime.wire) + await enqueueDomainDisableReplies(on: runtime.wire) + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + let startupTask = try #require(context.startupTaskForTesting()) + try await waitUntil { + runtime.wire.observations.commands + .contains { $0.method == "DOM.getDocument" } + } - await container.close() - #expect(context.state == .detached) + await container.close() + #expect(context.state == .detached) - await gate.open() - await startupTask.value + gate.open() + await startupTask.value + _ = await runtime.wire.observations.waitForCommands(method: "Inspector.disable", count: 1) - #expect(context.state == .detached) - #expect(context.rootNode == nil) + #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"), - ]) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == Array(startupCommands.prefix(5)) + [ + "Runtime.disable", + "Network.disable", + "Inspector.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) + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let gate = await runtime.wire.deferReply(to: "Runtime.enable") + + await runtime.wire.respond(to: "Inspector.enable") + await runtime.wire.respond(to: "Inspector.initialized") + + let container = WebInspectorContainer(proxy: runtime.proxy) + let context = container.mainContext + try await waitForStartupSubscribers(runtime: runtime, target: target) + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + ] + } - 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"), - ] + await runtime.wire.respond(to: "Inspector.disable") + await context.stop() + #expect(context.state == .detached) + + await runtime.wire.respond(to: "Runtime.disable") + gate.open() + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Inspector.disable", + "Runtime.disable", + ] + } + #expect(context.state == .detached) } - #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) + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let registry = WebInspectorDomainEnablementRegistry() + let gate = await runtime.wire.deferReply(to: "Runtime.enable") - let acquireTask = Task { - try await registry.acquire(.runtime, on: target) - } + let acquireTask = Task { + try await registry.acquire(.runtime, on: target) + } - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable") - ] - } + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Runtime.enable" + ] + } - let releaseTask = Task { - await registry.release(.runtime, on: target) - } + 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() + await runtime.wire.respond(to: "Runtime.disable") + gate.open() - try await acquireTask.value - #expect(await releaseTask.value == nil) + 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"), - ]) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Runtime.enable", + "Runtime.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() + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let registry = WebInspectorDomainEnablementRegistry() + let disableGate = await runtime.wire.deferReply(to: "Runtime.disable") - 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") + await runtime.wire.respond(to: "Runtime.enable") + try await registry.acquire(.runtime, on: target) - 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 releaseTask = Task { + await registry.release(.runtime, on: target) + } + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.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"), - ]) + let acquireWaitBaseline = await registry.acquireWaitingForDisableSequenceForTesting + let acquireTask = Task { + try await registry.acquire(.runtime, on: target) + } + await registry.waitForAcquireWaitingForDisableForTesting(after: acquireWaitBaseline) + #expect(runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.disable", + ]) - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await disableGate.open() + await runtime.wire.respond(to: "Runtime.enable") + 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"), - ]) + #expect(await releaseTask.value == nil) + try await acquireTask.value + #expect(runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.disable", + "Runtime.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() + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let registry = WebInspectorDomainEnablementRegistry() + let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") + let disableGate = await runtime.wire.deferReply(to: "Runtime.disable") + + let firstAcquireTask = Task { + try await registry.acquire(.runtime, on: target) + } + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Runtime.enable" + ] + } - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: enableGate) - await runtime.backend.hold(domain: "Runtime", method: "disable", gate: disableGate) + 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) - let firstAcquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable") - ] - } + enableGate.open() + try await waitUntil { + runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.disable", + ] + } + #expect(runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.disable", + ]) - 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.wire.respond(to: "Runtime.enable") + disableGate.open() - 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"), - ] + try await firstAcquireTask.value + #expect(await releaseTask.value == nil) + try await secondAcquireTask.value + #expect(runtime.wire.observations.commandMethods == [ + "Runtime.enable", + "Runtime.disable", + "Runtime.enable", + ]) } - #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() + try await withDataKitTestRuntime { runtime in + 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 runtime.wire.respond(to: "Runtime.enable") + try await registry.acquire(.runtime, on: target) + try await registry.acquire(.runtime, on: target) - await registry.discardLease(.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) + await runtime.wire.respond(to: "Runtime.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"), - ]) + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Runtime.enable", + "Runtime.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"), - ]) + try await withDataKitTestRuntime { runtime in + 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.wire.respond(to: "Runtime.enable") + try await registry.acquire(.runtime, on: oldPage) + try await registry.acquire(.runtime, on: oldPage) + + await registry.discardLease(.runtime, on: oldPage) + await runtime.wire.respond(to: "Runtime.enable") + try await registry.acquire(.runtime, on: newPage) + + await registry.discardLease(.runtime, on: oldPage) + try await registry.acquire(.runtime, on: newPage) + + let commands = runtime.wire.observations.commands + #expect(commands.map(\.method) == [ + "Runtime.enable", + "Runtime.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( + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("request-1") + + try await runtime.wire.emitRaw( + .requestWillBeSent( id: requestID, - url: "https://example.com/data.json", - method: "GET", - headers: ["Accept": "application/json"] + request: Network.Request( + id: requestID, + url: "https://example.com/data.json", + method: "GET", + headers: ["Accept": "application/json"] + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 ), - 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") + target: target + ) + try await runtime.wire.emitRaw( + .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 ), - 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 - ) + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: 12, encodedDataLength: 5, timestamp: 3), + target: target + ) + try await runtime.wire.emitRaw( + .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) + 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"] + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("response-first-request") + + try await runtime.wire.emitRaw( + .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 ), - 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 - ) + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: 9, encodedDataLength: 4, timestamp: 3), + target: target + ) + try await runtime.wire.emitRaw( + .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) + 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 @@ -5905,1485 +6036,1550 @@ func responseReceivedWithoutResourceTypePreservesRequestResourceType() async thr @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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("request-with-terminal-metadata") - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.state == .finished + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), + resourceType: .script, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 2, timestamp: 2), + target: target + ) + try await runtime.wire.emitRaw( + .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) } - 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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("request-with-negative-terminal-metrics") - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.state == .finished + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/negative", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 4, timestamp: 2), + target: target + ) + try await runtime.wire.emitRaw( + .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) } - 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") + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("redirected-request") + + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/redirect", method: "GET"), + resourceType: .document, + redirectResponse: nil, + timestamp: 1 ), - resourceType: .document, - timestamp: 2 - ), - target: target - ) + target: target + ) + try await runtime.wire.emitRaw( + .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 - } + 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 - ) + try await runtime.wire.emitRaw( + .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) + 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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("reused-request") - 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 runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/first", method: "GET"), + resourceType: .document, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) + try await runtime.wire.emitRaw( + .loadingFinished( + id: requestID, + timestamp: 2, + sourceMapURL: "first.map", + metrics: Network.Metrics(encodedDataLength: 20, decodedBodyLength: 40) + ), + target: target + ) - try await waitUntil { - request.url == "https://example.com/second" && request.state == .pending + let results: WebInspectorFetchedResults = context.fetchedResults() + try await waitUntil { results.items.first?.state == .finished } + let request = try #require(results.items.first) + + try await runtime.wire.emitRaw( + .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) } - #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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("failed-request") - 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) + try await runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request(id: requestID, url: "https://example.com/fail", method: "GET"), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: target + ) + try await runtime.wire.emitRaw( + .dataReceived(id: requestID, dataLength: -10, encodedDataLength: -20, timestamp: 2), + target: target + ) + try await runtime.wire.emitRaw( + .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 + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("cached-request") + + try await runtime.wire.emitRaw( + .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 ), - resourceType: .stylesheet, - timestamp: 5 - ), - target: target - ) + 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) + 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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + + await #expect(throws: RawWireFixtureError.missingRequiredField( + method: "Network.requestServedFromMemoryCache", + field: "resource.url" + )) { + try await runtime.wire.emitRaw( + .requestServedFromMemoryCache( + id: Network.Request.ID("cached-request-without-url"), + response: Network.Response(status: 200), + resourceType: nil, + timestamp: 5 + ), + target: target + ) + } + try await runtime.wire.emitRaw( + .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) + 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") + try await withDataKitTestRuntime { runtime in + 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 - ) + try await runtime.wire.emitRaw( + .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) + 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( + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("websocket-created-after-request") + + try await runtime.wire.emitRaw( + .requestWillBeSent( id: requestID, - url: "wss://example.com/socket", - method: "GET", - headers: ["Upgrade": "websocket"] + request: Network.Request( + id: requestID, + url: "wss://example.com/socket", + method: "GET", + headers: ["Upgrade": "websocket"] + ), + resourceType: .webSocket, + redirectResponse: nil, + timestamp: 1 ), - 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"] + target: target + ) + try await runtime.wire.emitRaw( + .responseReceived( + id: requestID, + response: Network.Response( + status: 101, + statusText: "Switching Protocols", + headers: ["Upgrade": "websocket"], + requestHeaders: ["Upgrade": "websocket"] + ), + resourceType: .webSocket, + timestamp: 2 ), - resourceType: .webSocket, - timestamp: 2 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), - target: target - ) + target: target + ) + try await runtime.wire.emitRaw( + .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( + 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) + + try await runtime.wire.emitRaw( + .webSocket(.created(id: requestID, url: "wss://example.com/socket?created")), + target: target + ) + try await waitUntil { request.url == "wss://example.com/socket?created" } + try await runtime.wire.emitRaw( + .webSocket(.handshakeRequest( 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) + request: Network.Request( + id: requestID, + url: "", + method: "GET", + headers: ["Upgrade": "websocket"] + ), + timestamp: nil + )), + target: target + ) + try await runtime.wire.emitRaw( + .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") + try await withDataKitTestRuntime { runtime in + 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) + try await runtime.wire.emitRaw( + .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( + try await runtime.wire.emitRaw( + .webSocket(.handshakeRequest( 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 + 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) + + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .webSocket(.frameSent( + id: requestID, + frame: Network.WebSocketFrame(opcode: 1, mask: true, payloadData: "hello", payloadLength: 5), + timestamp: 3 + )), + target: target + ) + try await runtime.wire.emitRaw( + .webSocket(.frameReceived( + id: requestID, + frame: Network.WebSocketFrame(opcode: 1, mask: false, payloadData: "world", payloadLength: 5), + timestamp: 4 + )), + target: target + ) + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .webSocket(.closed(id: requestID, timestamp: 6)), + target: target + ) + try await waitUntil { + request.webSocket?.readyState == .closed && request.state == .finished + } + #expect(request.finishedOrFailedTimestamp == 6) } - #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") + try await withDataKitTestRuntime { runtime in + 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 - ) + try await runtime.wire.emitRaw( + .webSocket(.handshakeResponse( + id: requestID, + response: Network.Response(status: 101), + timestamp: 1 + )), + target: target + ) + try await runtime.wire.emitRaw( + .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) + 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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let results: WebInspectorFetchedResults = context.fetchedResults() + + let baseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw( + .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) + #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( + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("form-request") + + try await runtime.wire.emitRaw( + .requestWillBeSent( 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" + 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 ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) + 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) + 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 - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("form-request-hints") - 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"] + try await runtime.wire.emitRaw( + .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 ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) + 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) -} + 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") -@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( + try await runtime.wire.emitRaw( + .responseReceived( id: requestID, - url: "https://example.com/api/data.json", - method: "GET" + response: Network.Response( + status: 200, + headers: ["Content-Type": "text/plain"], + requestHeaders: ["Content-Type": "application/x-www-form-urlencoded"] + ), + resourceType: .fetch, + timestamp: 2 ), - 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 - ) + 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) + 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) + } +} - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 3, sourceMapURL: nil, metrics: nil), - target: target - ) - try await waitUntil { request.canFetchResponseBody } +@MainActor +@Test +func responseBodyPublishesHintsAndFetchLifecycle() async throws { + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("json-response-body") - await runtime.backend.enqueue( - Network.Body(data: #"{"ok":true}"#, base64Encoded: false), - for: "Network", - method: "getResponseBody" - ) + try await runtime.wire.emitRaw( + .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 + ) + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .loadingFinished(id: requestID, timestamp: 3, sourceMapURL: nil, metrics: nil), + target: target + ) + try await waitUntil { request.canFetchResponseBody } - 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) + await runtime.wire.respond( + to: "Network.getResponseBody", + with: try rawNetworkBodyResult( + Network.Body(data: #"{"ok":true}"#, base64Encoded: false) + ) + ) - let commandsBeforeSecondFetch = await runtime.backend.recordedCommands() - await request.fetchResponseBody() - let commandsAfterSecondFetch = await runtime.backend.recordedCommands() - #expect(commandsAfterSecondFetch == commandsBeforeSecondFetch) + 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 = runtime.wire.observations.commands + await request.fetchResponseBody() + let commandsAfterSecondFetch = runtime.wire.observations.commands + #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") + try await withDataKitTestRuntime { runtime in + 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) + try await emitFinishedRequest(id: loadedID, target: target, backend: runtime.wire) + try await emitFinishedRequest(id: failedID, target: target, backend: runtime.wire) - 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) }) + 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 runtime.wire.respond( + to: "Network.getResponseBody", + with: try rawNetworkBodyResult( + Network.Body(data: "hello", base64Encoded: false) + ) + ) + await runtime.wire.fail( + "Network.getResponseBody", + message: "Response body is unavailable." + ) - await loadedRequest.fetchResponseBody() - #expect(loadedRequest.responseBody.phase == .loaded) - #expect(loadedRequest.responseBody.text == "hello") - #expect(loadedRequest.responseBody.isBase64Encoded == false) + 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 - } + 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, got \(error).") + return + } - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Network", method: "getResponseBody"))) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "Network.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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("cleared-body-request") + let gate = await runtime.wire.deferReply( + to: "Network.getResponseBody", + with: try rawNetworkBodyResult( + Network.Body(data: "stale-body", base64Encoded: false) + ) + ) - let fetchTask = Task { - await request.fetchResponseBody() - } - try await waitUntil { - await runtime.backend.recordedCommands().contains(RecordedCommand(domain: "Network", method: "getResponseBody")) - } + try await emitFinishedRequest(id: requestID, target: target, backend: runtime.wire) + 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 + let fetchTask = Task { + await request.fetchResponseBody() + } + try await waitUntil { + runtime.wire.observations.commands.contains { $0.method == "Network.getResponseBody" } + } - await 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 + await context.clearNetworkRequests() + #expect(context.registeredRequest(for: NetworkRequest.ID(requestID)) == nil) + gate.open() + await fetchTask.value - #expect(body.phase == NetworkBody.Phase.fetching) - #expect(body.text == nil) - #expect(request.responseBody === body) + #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")) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let requestID = Network.Request.ID("request-1") + + let results: WebInspectorFetchedResults = context.fetchedResults() + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .messageRepeatCountUpdated(count: 3, timestamp: 2), + target: target + ) + try await waitUntil { message.repeatCount == 3 } + #expect(results.items.first === message) + #expect(message.timestamp == 2) + + try await runtime.wire.emitRaw( + .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.wire.respond(to: "Runtime.releaseObjectGroup") + try await runtime.wire.emitRaw( + .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 { + runtime.wire.observations.commands + .contains { $0.method == "Runtime.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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let allResults: WebInspectorFetchedResults = context.fetchedResults() + + try await runtime.wire.emitRaw( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "warning"), + text: "middle" + )), + target: target + ) + try await runtime.wire.emitRaw( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "javascript"), + level: Console.Level(rawValue: "error"), + text: "zeta" + )), + target: target + ) + try await runtime.wire.emitRaw( + .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"]) + #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"] - } + try await runtime.wire.emitRaw( + .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 - )) + warningResults.updateFetchDescriptor(WebInspectorFetchDescriptor( + sortBy: [SortDescriptor(\.text)], + fetchLimit: 2, + fetchOffset: 1 + )) - try await waitUntil { - warningResults.items.map(\.text) == ["omega", "zebra"] + 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) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let objectID = Runtime.RemoteObject.ID("console-object") + let results: WebInspectorFetchedResults = context.fetchedResults() + + try await runtime.wire.emitRaw( + .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") + + try await runtime.wire.emitRaw( + .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") + #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 withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let objectID = Runtime.RemoteObject.ID("console-stale-object") + let results: WebInspectorFetchedResults = context.fetchedResults() + + try await runtime.wire.emitRaw( + .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.wire.respond(to: "Runtime.releaseObjectGroup") + try await runtime.wire.emitRaw( + .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + target: target + ) - try await waitUntil { - await runtime.backend.recordedCommands() - .contains(RecordedCommand(domain: "Runtime", method: "releaseObjectGroup")) - } - let releaseObjectGroupCommands = await runtime.backend.recordedCommands().filter { - $0 == RecordedCommand(domain: "Runtime", method: "releaseObjectGroup") - } - #expect(releaseObjectGroupCommands.count == 1) - 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.")) + try await waitUntil { + runtime.wire.observations.commands + .contains { $0.method == "Runtime.releaseObjectGroup" } + } + let releaseObjectGroupCommands = runtime.wire.observations.commands.filter { + $0.method == "Runtime.releaseObjectGroup" + } + #expect(releaseObjectGroupCommands.count == 1) + 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) } - #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" - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("main") + let objectID = Runtime.RemoteObject.ID("evaluation-result") + + try await runtime.wire.emitRaw( + .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.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: objectID, + kind: .string, + description: "hello", + value: .string("hello") + ), + wasThrown: true, + savedResultIndex: 7 + )) + ) - 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 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) + let commands = runtime.wire.observations.commands + let command = try #require(commands.last { $0.method == "Runtime.evaluate" }) + #expect(try commandStringParameter(command, "expression") == "throw 'hello'") + #expect(try commandStringParameter(command, "contextId") == contextID.unscopedRawValue) + } } @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") + try await withDataKitTestRuntime { runtime in + 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.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: objectID, kind: .object, description: "root") + )) + ) + let evaluation = try await context.evaluate("window") + + await runtime.wire.respond( + to: "Runtime.getProperties", + with: try rawRuntimePropertiesResult([ + 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") + ), + ]) + ) - 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 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.wire.respond( + to: "Runtime.getCollectionEntries", + with: try rawRuntimeCollectionEntriesResult([ + Runtime.CollectionEntry( + key: Runtime.RemoteObject(id: nil, kind: .string, description: "key", value: .string("key")), + value: Runtime.RemoteObject(id: entryValueID, kind: .object, description: "entry value") + ) + ]) + ) - 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 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"))) + let commands = runtime.wire.observations.commands + #expect(commands.contains { $0.method == "Runtime.getProperties" }) + #expect(commands.contains { $0.method == "Runtime.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") + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("main") + let objectID = Runtime.RemoteObject.ID("stale-object") + + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Main", + kind: .normal + )), + target: target + ) + try await waitUntil { context.executionContexts.count == 1 } - await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil - } + await runtime.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: objectID, kind: .object, description: "stale") + )) + ) + let evaluation = try await context.evaluate("window") - 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.")) + try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) + try await waitUntil { + context.executionContexts.isEmpty && context.selectedContext == nil + } + + 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.")) + } + #expect(context.state == .attached) } - #expect(context.state == .attached) } @MainActor @Test func runtimeEvaluationRejectsReplyAfterRuntimeClearWithoutKnownContexts() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - #expect(context.executionContexts.isEmpty) - let gate = WebInspectorTestGate() - await runtime.backend.hold(domain: "Runtime", method: "evaluate", gate: gate) - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-evaluation-reply"), - kind: .object - ) - ), - for: "Runtime", - method: "evaluate" - ) + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + #expect(context.executionContexts.isEmpty) + let gate = await runtime.wire.deferReply( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-evaluation-reply"), + kind: .object + ) + )) + ) - let evaluationTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await context.evaluate("window") - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected evaluation error: \(error)") - return nil + let evaluationTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await context.evaluate("window") + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected evaluation error: \(error)") + return nil + } } + _ = await runtime.wire.observations.waitForCommands(method: "Runtime.evaluate", count: 1) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + gate.open() + + #expect(await evaluationTask.value == .disconnected( + "Runtime evaluation target is no longer current in this WebInspectorContext." + )) + #expect(context.state == .attached) } - _ = await runtime.backend.waitForRecordedCommands(domain: "Runtime", method: "evaluate", count: 1) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - await gate.open() - - #expect(await evaluationTask.value == .disconnected( - "Runtime evaluation target is no longer current in this WebInspectorContext." - )) - #expect(context.state == .attached) } @MainActor @Test func runtimePropertiesRejectReplyAfterObjectClear() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("properties-race") - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Properties race", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("properties-root"), - kind: .object - ) - ), - for: "Runtime", - method: "evaluate" - ) - let root = try await context.evaluate("window").object - let gate = WebInspectorTestGate() - await runtime.backend.hold(domain: "Runtime", method: "getProperties", gate: gate) - await runtime.backend.enqueue( - [ - Runtime.PropertyDescriptor( - name: "child", - value: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-property-child"), + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("properties-race") + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Properties race", + kind: .normal + )), + target: target + ) + try await waitUntil { context.executionContexts.count == 1 } + await runtime.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("properties-root"), kind: .object ) - ) - ], - for: "Runtime", - method: "getProperties" - ) + )) + ) + let root = try await context.evaluate("window").object + let gate = await runtime.wire.deferReply( + to: "Runtime.getProperties", + with: try rawRuntimePropertiesResult([ + Runtime.PropertyDescriptor( + name: "child", + value: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-property-child"), + kind: .object + ) + ) + ]) + ) - let propertiesTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await root.properties() - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected Runtime.getProperties error: \(error)") - return nil + let propertiesTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await root.properties() + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected Runtime.getProperties error: \(error)") + return nil + } } + _ = await runtime.wire.observations.waitForCommands(method: "Runtime.getProperties", count: 1) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + gate.open() + + #expect(await propertiesTask.value == .disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) + #expect(context.state == .attached) } - _ = await runtime.backend.waitForRecordedCommands(domain: "Runtime", method: "getProperties", count: 1) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - await gate.open() - - #expect(await propertiesTask.value == .disconnected( - "RuntimeObject is not registered in this WebInspectorContext." - )) - #expect(context.state == .attached) } @MainActor @Test func runtimeCollectionEntriesRejectReplyAfterObjectClear() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("entries-race") - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Entries race", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("entries-root"), - kind: .object - ) - ), - for: "Runtime", - method: "evaluate" - ) - let root = try await context.evaluate("new Map()").object - let gate = WebInspectorTestGate() - await runtime.backend.hold(domain: "Runtime", method: "getCollectionEntries", gate: gate) - await runtime.backend.enqueue( - [ - Runtime.CollectionEntry( - value: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-entry-value"), + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let contextID = Runtime.ExecutionContext.ID("entries-race") + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Entries race", + kind: .normal + )), + target: target + ) + try await waitUntil { context.executionContexts.count == 1 } + await runtime.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("entries-root"), kind: .object ) - ) - ], - for: "Runtime", - method: "getCollectionEntries" - ) + )) + ) + let root = try await context.evaluate("new Map()").object + let gate = await runtime.wire.deferReply( + to: "Runtime.getCollectionEntries", + with: try rawRuntimeCollectionEntriesResult([ + Runtime.CollectionEntry( + value: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("stale-entry-value"), + kind: .object + ) + ) + ]) + ) - let entriesTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await root.collectionEntries() - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected Runtime.getCollectionEntries error: \(error)") - return nil + let entriesTask = Task { @MainActor () -> WebInspectorProxyError? in + do { + _ = try await root.collectionEntries() + return nil + } catch let error as WebInspectorProxyError { + return error + } catch { + Issue.record("Unexpected Runtime.getCollectionEntries error: \(error)") + return nil + } } + _ = await runtime.wire.observations.waitForCommands(method: "Runtime.getCollectionEntries", count: 1) + let eventBaseline = context.eventPumpAppliedSequenceForTesting + try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) + gate.open() + + #expect(await entriesTask.value == .disconnected( + "RuntimeObject is not registered in this WebInspectorContext." + )) + #expect(context.state == .attached) } - _ = await runtime.backend.waitForRecordedCommands( - domain: "Runtime", - method: "getCollectionEntries", - count: 1 - ) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - await gate.open() - - #expect(await entriesTask.value == .disconnected( - "RuntimeObject is not registered in this WebInspectorContext." - )) - #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 + try await withDataKitTestRuntime { runtime in + let (target, context) = try await startContext(runtime: runtime) + let mainID = Runtime.ExecutionContext.ID("main") + let utilityID = Runtime.ExecutionContext.ID("utility") + + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .executionContextCreated(Runtime.ExecutionContext( + id: mainID, + name: "Main Updated", + kind: .normal + )), + target: target + ) + try await waitUntil { mainContext.name == "Main Updated" } + #expect(context.executionContexts.first === mainContext) + + try await runtime.wire.emitRaw( + .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) + + try await runtime.wire.emitRaw( + .executionContextDestroyed(utilityID), + target: target + ) + try await waitUntil { + context.executionContexts.count == 1 && context.selectedContext === mainContext + } + #expect(context.executionContexts.first === mainContext) + + try await runtime.wire.emitRaw( + .executionContextsCleared(target: target.id), + target: target + ) + try await waitUntil { + context.executionContexts.isEmpty && context.selectedContext == nil + } } - #expect(context.executionContexts.first === mainContext) +} - await runtime.backend.emit( - .executionContextsCleared(target: target.id), - target: target - ) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil +@MainActor +@Test +func dataKitTestRuntimeScopeQuiescesPendingWorkAndReleasesOwners() async throws { + weak var weakWire: DataKitRawWireDriver? + weak var weakPeer: WebInspectorTestPeer? + weak var weakProxy: WebInspectorProxy? + var observations: DataKitRawWireDriver.Observations? + var observationWaiter: Task<[WebInspectorTestPeer.Command], Never>? + var pendingReply: Task? + + try await withDataKitTestRuntime { runtime in + weakWire = runtime.wire + weakPeer = runtime.peer + weakProxy = runtime.proxy + + let runtimeObservations = runtime.wire.observations + observations = runtimeObservations + observationWaiter = Task { + await runtimeObservations.waitForCommands( + method: "Test.neverReceived", + count: 1 + ) + } + try await waitUntil { + runtimeObservations.pendingWaiterCountForTesting == 1 + } + + _ = await runtime.wire.deferReply(to: "Page.reload") + let page = runtime.page + pendingReply = Task { + try await page.page.reload() + } + _ = await runtimeObservations.waitForCommands(method: "Page.reload", count: 1) + } + + let finishedObservationWaiter = try #require(observationWaiter) + #expect(await finishedObservationWaiter.value.isEmpty) + #expect(observations?.pendingWaiterCountForTesting == 0) + + let finishedReply = try #require(pendingReply) + await #expect(throws: WebInspectorProxyError.closed) { + try await finishedReply.value } + + #expect(weakWire == nil) + #expect(weakPeer == nil) + #expect(weakProxy == nil) } @MainActor private func startContext( - runtime: WebInspectorProxyTestRuntime, + runtime: DataKitTestRuntime, 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) + try await enqueueStartupReplies(on: runtime.wire, document: document) let container = WebInspectorContainer(proxy: runtime.proxy) let context = container.mainContext @@ -7392,132 +7588,119 @@ private func startContext( return (target, context) } -private var startupCommands: [RecordedCommand] { +private var startupCommands: [String] { [ - 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"), + "Inspector.enable", + "Inspector.initialized", + "Runtime.enable", + "Network.enable", + "DOM.getDocument", + "Console.enable", ] } -private var shutdownCommands: [RecordedCommand] { +private var shutdownCommands: [String] { [ - RecordedCommand(domain: "Console", method: "disable"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Network", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), + "Console.disable", + "Runtime.disable", + "Network.disable", + "Inspector.disable", ] } private func enqueueStartupReplies( - on backend: WebInspectorTestBackend, + on backend: DataKitRawWireDriver, document: DOM.Node = DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") -) async { +) async throws { await enqueueDomainEnableReplies(on: backend) - await backend.enqueue(document, for: "DOM", method: "getDocument") + await backend.respond( + to: "DOM.getDocument", + with: try domDocumentResult(document) + ) } -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 enqueueDomainEnableReplies(on backend: DataKitRawWireDriver) async { + await backend.respond(to: "Inspector.enable") + await backend.respond(to: "Inspector.initialized") + await backend.respond(to: "Runtime.enable") + await backend.respond(to: "Network.enable") + await backend.respond(to: "Console.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 enqueueDomainDisableReplies(on backend: DataKitRawWireDriver) async { + await backend.respond(to: "Console.disable") + await backend.respond(to: "Runtime.disable") + await backend.respond(to: "Network.disable") + await backend.respond(to: "Inspector.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 enqueueCSSStyleReplies(on backend: DataKitRawWireDriver) async throws { + await backend.respond( + to: "CSS.getMatchedStylesForNode", + with: try testJSONObject( + #"{"matchedCSSRules":[{"rule":{"selectorList":{"selectors":[{"text":".card"}],"text":".card"},"origin":"regular","style":{"styleId":{"styleSheetId":"style-1","ordinal":0},"cssProperties":[{"name":"display","value":"grid","text":"display: grid;"}],"cssText":"display: grid;"}}}]}"# + ) + ) + await backend.respond( + to: "CSS.getInlineStylesForNode", + with: try testJSONObject("{}") + ) + await backend.respond( + to: "CSS.getComputedStyleForNode", + with: try testJSONObject( + #"{"computedStyle":[{"name":"display","value":"grid"}]}"# + ) ) } -private func matchedStylesCommandCount(on backend: WebInspectorTestBackend) async -> Int { - await backend.recordedCommands() - .filter { $0 == RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode") } +private func matchedStylesCommandCount(on backend: DataKitRawWireDriver) -> Int { + backend.observations.commands + .filter { $0.method == "CSS.getMatchedStylesForNode" } .count } @MainActor private func waitForStartupSubscribers( - runtime: WebInspectorProxyTestRuntime, + runtime: DataKitTestRuntime, 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) + _ = runtime + await target.waitForModelEventSubscriptions() } private func emitFinishedRequest( id: Network.Request.ID, target: WebInspectorTarget, - backend: WebInspectorTestBackend -) async { - await backend.emit( - .requestWillBeSent( - id: id, - request: Network.Request(id: id, url: "https://example.com/\(id)", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target + backend: DataKitRawWireDriver +) async throws { + let targetID = target.pageBindingID ?? "page-main" + try await backend.emitTargetEvent( + targetID: targetID, + method: "Network.requestWillBeSent", + parameters: try testJSONObject( + """ + {"requestId":"\(id.rawValue)","request":{"url":"https://example.com/\(id.rawValue)","method":"GET","headers":{}},"type":"Fetch","timestamp":1} + """ + ) ) - await backend.emit( - .responseReceived( - id: id, - response: Network.Response(status: 200, mimeType: "text/plain"), - resourceType: .fetch, - timestamp: 2 - ), - target: target + try await backend.emitTargetEvent( + targetID: targetID, + method: "Network.responseReceived", + parameters: try testJSONObject( + """ + {"requestId":"\(id.rawValue)","response":{"status":200,"mimeType":"text/plain"},"type":"Fetch","timestamp":2} + """ + ) + ) + try await backend.emitTargetEvent( + targetID: targetID, + method: "Network.loadingFinished", + parameters: try testJSONObject( + """ + {"requestId":"\(id.rawValue)","timestamp":3} + """ + ) ) - await backend.emit(.loadingFinished(id: id, timestamp: 3, sourceMapURL: nil, metrics: nil), target: target) } @MainActor diff --git a/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift b/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift deleted file mode 100644 index b9e34923..00000000 --- a/Tests/WebInspectorProxyKitTests/AnySendableValueTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Testing -import WebInspectorProxyKit -import WebInspectorProxyKitTesting - -private actor ReferenceValue {} - -@Test -func anySendableValuePreservesReferenceIdentity() throws { - let value = ReferenceValue() - let erased = AnySendableValue(value) - - let restored = try #require(erased.cast(as: ReferenceValue.self)) - #expect(restored === value) -} - -@Test -func anySendableValueReturnsNilForTheWrongType() { - let erased = AnySendableValue(42) - - #expect(erased.cast(as: String.self) == nil) -} - -@Test -func recordedCommandPreservesItsTypedPayload() throws { - let nodeID = DOM.Node.ID("parent") - let payload = DOM.RequestChildNodesPayload(id: nodeID, depth: 2) - let command = WebInspectorProxyCommand( - targetID: WebInspectorTarget.ID("page"), - route: RoutingTargetID("page"), - domain: .dom, - method: "requestChildNodes", - payload: payload - ) - - let recorded = RecordedCommand(command: command) - let restored = try #require( - recorded.payload.cast(as: DOM.RequestChildNodesPayload.self) - ) - #expect(restored.id == nodeID) - #expect(restored.depth == 2) -} diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift index 8d2efee8..10bbc108 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift @@ -3,579 +3,287 @@ 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" + let operation = Task { + try await target.dom.getDocument() + } + 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}}"## + ) ) - 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) + let node = try await operation.value + #expect(node.id == DOM.Node.ID("document")) + await runtime.close() } @Test -func domRequestNodeDispatchesToTargetRoute() async throws { +func rawPeerRoutesFrameRequestNodeThroughCurrentPageDOMAgent() 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) + 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 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"}"#) + ) - 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) + #expect(try await operation.value == DOM.Node.ID("selected-node")) + await runtime.close() } @Test -func inspectorInspectResolvesNodeRemoteObjectToDOMInspectEvent() async throws { +func rawInspectorEventResolvesNodeThroughProductionCommandPath() 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() } + await runtime.proxy.waitForEventSubscription( + targetID: target.id, + route: target.route, + domain: .dom + ) + await runtime.proxy.waitForEventSubscription( + targetID: target.id, + route: target.route, + domain: .inspector + ) - 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") + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Inspector.inspect", + parameters: try jsonObject( + #"{"object":{"type":"object","subtype":"node","objectId":"remote-node"},"hints":{}}"# + ) + ) - await runtime.backend.emit( - .inspect( - Runtime.RemoteObject( - id: objectID, - kind: .object, - subtype: Runtime.Subtype(rawValue: "node") - ), - hints: .object([:]) - ), - target: target + let 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 event = try #require(try await value(of: eventTask)) + let event = try #require(await eventTask.value) guard case let .inspect(nodeID) = event else { Issue.record("Expected Inspector.inspect to resolve to DOM.inspect.") + await runtime.close() 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) + #expect(nodeID == DOM.Node.ID("selected-node")) + await runtime.close() } @Test -func domInspectEventPassesThroughWithoutRequestNode() async throws { +func rawFrameNetworkEventIsScopedOnSemanticCurrentPageRoute() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let expectedNodeID = DOM.Node.ID("protocol-node") - + let page = try await runtime.proxy.waitForCurrentPage() + try await runtime.peer.createTarget(.init( + id: "frame-target", + type: "frame", + frameID: "child-frame", + parentFrameID: "main-frame" + )) let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() + var iterator = page.network.events.makeAsyncIterator() return await iterator.next() } - - 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) -} - -@Test -func inspectorInspectIgnoresNonNodeRemoteObject() 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.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) - - await runtime.backend.emit( - .inspect( - Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("function-object"), - kind: .function - ), - hints: .object([:]) - ), - target: target - ) - - 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.proxy.waitForEventSubscription( + targetID: page.id, + route: page.route, + domain: .network ) - await runtime.backend.emit( - .responseReceived( - id: Network.Request.ID("second-request"), - response: Network.Response(status: 201), - resourceType: .xhr, + try await runtime.peer.emitTargetEvent( + targetID: "frame-target", + method: "Network.responseReceived", + parameters: try responseReceivedParameters( + requestID: "frame-request", + 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") - ) - - 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)) - - guard case let .responseReceived(originalID, _, _, _) = originalEvent else { - Issue.record("Expected original route to receive Network.responseReceived.") + guard case let .responseReceived(frameID, _, _, _) = try #require(await eventTask.value) else { + Issue.record("Expected Network.responseReceived events.") + await runtime.close() 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(frameID == Network.Request.ID( + "frame-request", + scopedToTargetRawValue: "frame-target" + )) + await runtime.close() } -@Test -func networkLoadingFinishedCarriesTerminalMetadata() async throws { +@Test(arguments: ["Network", "Console", "Runtime", "CSS"]) +func rawPeerDispatchesDomainEnableAndDisable(_ domain: String) 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() + let enableTask = Task { + switch domain { + case "Network": try await target.network.enable() + case "Console": try await target.console.enable() + case "Runtime": try await target.runtime.enable() + case "CSS": try await target.css.enable() + default: preconditionFailure("Unexpected test domain.") + } } - - 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 + let enable = try await runtime.peer.commands.next() + #expect(enable.destination == .target("page-main")) + #expect(enable.method == "\(domain).enable") + try await runtime.peer.reply(to: enable) + try await enableTask.value + + let disableTask = Task { + switch domain { + case "Network": try await target.network.disable() + case "Console": try await target.console.disable() + case "Runtime": try await target.runtime.disable() + case "CSS": try await target.css.disable() + default: preconditionFailure("Unexpected test domain.") + } } - #expect(id == Network.Request.ID("terminal-request")) - #expect(timestamp == 8) - #expect(sourceMapURL == "terminal.js.map") - #expect(metrics?.encodedDataLength == 256) - #expect(metrics?.decodedBodyLength == 512) + let disable = try await runtime.peer.commands.next() + #expect(disable.destination == .target("page-main")) + #expect(disable.method == "\(domain).disable") + try await runtime.peer.reply(to: disable) + try await disableTask.value + await runtime.close() } @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" - ) - _ = try await page.runtime.properties(of: objectID) - - await runtime.backend.enqueue( - Runtime.ObjectPreview(kind: .object, description: "preview"), - for: "Runtime", - method: "getPreview" + scopedToTargetRawValue: "frame-target" ) - _ = 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 - } - group.addTask { - try await Task.sleep(for: timeout) - throw TimedOut() - } - guard let value = try await group.next() else { - throw TimedOut() +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.cancelAll() - return value - } + """ + ) } diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 74d04ed0..e11e3277 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -10,7 +10,7 @@ func transportCommandBackendDispatchesPageReloadThroughTargetRoute() async throw let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let reloadTask = Task { @@ -36,7 +36,7 @@ func transportCommandBackendDecodesDOMRequestNodeResult() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let requestNodeTask = Task { @@ -63,7 +63,7 @@ func transportCommandBackendPreservesDOMRequestChildNodesRecursiveDepth() async let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let requestChildrenTask = Task { @@ -91,7 +91,7 @@ func transportCommandBackendEncodesDOMEditingCommandsAndDecodesAttributes() asyn let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let attributesTask = Task { @@ -179,7 +179,7 @@ func transportCommandBackendEncodesDOMHighlightAndInspectModeCommands() async th let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let highlightTask = Task { @@ -254,7 +254,7 @@ func transportCommandBackendDecodesDOMDocumentResult() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let documentTask = Task { @@ -290,7 +290,7 @@ func transportCommandBackendEncodesAndDecodesCSSStyleCommands() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let matchedStylesTask = Task { @@ -616,7 +616,7 @@ func nativeFatalCallbackPropagatesThroughProxyTerminalAPI() async throws { @Test func proxyWaitUntilClosedReturnsImmediatelyAfterClose() async throws { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) await proxy.close() @@ -631,7 +631,7 @@ func proxyHandleDeallocatesAfterExplicitClose() async { weak var weakProxy: WebInspectorProxy? do { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) weakProxy = proxy await proxy.close() } @@ -644,7 +644,7 @@ func droppingOpenProxyHandleReachesConnectionCoreDeinit() { weak var weakProxy: WebInspectorProxy? do { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) weakProxy = proxy } @@ -659,7 +659,7 @@ func pageTargetAndDomainHandlesDoNotOwnTheProxyLifecycle() async { var dom: DOM? do { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) weakProxy = proxy page = proxy.page target = WebInspectorTarget( @@ -701,7 +701,7 @@ func pendingCapabilitySendDoesNotRetainDroppedConnectionCore() async throws { await transport.receiveRootMessage( #"{"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 proxy = try await WebInspectorProxy(transport: transport) let frame = WebInspectorTarget( id: WebInspectorTarget.ID("frame-target"), kind: .frame, @@ -736,7 +736,7 @@ func pendingCapabilitySendDoesNotRetainDroppedConnectionCore() async throws { @Test func proxyWaitUntilClosedWaitsForInFlightCloseConnection() async throws { let closeGate = CloseConnectionGate() - let proxy = WebInspectorProxy(closeConnection: { + let proxy = WebInspectorProxy(localStateOnly: (), closeConnection: { await closeGate.waitUntilReleased() }) @@ -761,7 +761,7 @@ func proxyWaitUntilClosedWaitsForInFlightCloseConnection() async throws { @Test func proxyWaitUntilClosedCancellationRemovesWaiter() async throws { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) let waitTask = Task { try await proxy.waitUntilClosed() @@ -1020,7 +1020,7 @@ func transportBackendDecodesRootScopedDOMDocumentUpdatedForCurrentPage() async t let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let eventTask = Task { @@ -1043,7 +1043,7 @@ func transportBackendDecodesDOMShadowAndPseudoEventsForTargetRoute() async throw let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let eventTask = Task { @@ -1116,7 +1116,7 @@ func transportBackendNormalizesInspectorInspectToDOMInspectEvent() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let eventTask = Task { @@ -1477,7 +1477,7 @@ func transportBackendDecodesNetworkResponseEventForTargetRoute() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let eventTask = Task { @@ -1512,7 +1512,7 @@ func transportBackendDecodesNetworkResponseEventWithoutType() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let eventTask = Task { @@ -1949,7 +1949,7 @@ func transportBackendDecodesWebSocketHandshakeEventsForTargetRoute() async throw let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let requestEventTask = Task { @@ -2010,7 +2010,7 @@ func transportBackendFiltersEventsByRoute() async throws { await transport.receiveRootMessage( #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"frame-1","isProvisional":false}}}"# ) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let page = pageTarget(proxy: proxy) let frame = WebInspectorTarget( id: WebInspectorTarget.ID("frame-target"), @@ -2060,8 +2060,8 @@ func transportBackendKeepsPeerSubscriberActiveWhenOneStreamTerminates() async th let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxyBackend = LiveWebInspectorProxyBackend(transport: transport) - let proxy = WebInspectorProxy(backend: proxyBackend) + let proxy = try await WebInspectorProxy(transport: transport) + let proxyBackend = try #require(proxy.structuredEventBackend as? LiveWebInspectorProxyBackend) let page = pageTarget(proxy: proxy) let probe = EventDeliveryProbe() @@ -2124,7 +2124,7 @@ func transportBackendRuntimeClearedUsesSemanticTargetID() 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 proxy = try await WebInspectorProxy(transport: transport) let retargeted = WebInspectorTarget( id: WebInspectorTarget.ID("semantic-page"), kind: .page, @@ -2160,7 +2160,7 @@ func transportCommandBackendDecodesRuntimeEvaluationResult() 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 proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let evaluateTask = Task { @@ -2194,7 +2194,7 @@ func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries( let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let target = pageTarget(proxy: proxy) let objectID = Runtime.RemoteObject.ID("object-1") @@ -3798,7 +3798,7 @@ func structuredPhysicalTargetScopeFinishesWithoutDisableWhenTargetIsDestroyed() await transport.receiveRootMessage( #"{"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 proxy = try await WebInspectorProxy(transport: transport) let frame = WebInspectorTarget( id: WebInspectorTarget.ID("frame-target"), kind: .frame, @@ -3856,7 +3856,7 @@ func structuredPhysicalTargetScopeFailsAcquisitionWhenTargetDiesDuringEnable() a await transport.receiveRootMessage( #"{"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 proxy = try await WebInspectorProxy(transport: transport) let frame = WebInspectorTarget( id: WebInspectorTarget.ID("frame-target"), kind: .frame, @@ -3904,7 +3904,7 @@ func structuredFixedTargetScopeFailsAtCommitWithoutRetargetingPendingEnable() as await transport.receiveRootMessage( #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"# ) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) + let proxy = try await WebInspectorProxy(transport: transport) let fixedPage = WebInspectorTarget( id: WebInspectorTarget.ID("page-main"), kind: .page, diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift new file mode 100644 index 00000000..24fa6f0d --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift @@ -0,0 +1,451 @@ +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 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.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 index c1bac60f..2281cda7 100644 --- a/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift +++ b/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift @@ -2,12 +2,43 @@ import Synchronization /// A deterministic, cancellation-aware suspension point for asynchronous tests. public final class WebInspectorTestGate: Sendable { - private final class Storage: 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 cancelledWaiterIDs: Set = [] + var registeringWaiterIDs: Set = [] var nextWaiterID: UInt64 = 0 } @@ -20,23 +51,27 @@ public final class WebInspectorTestGate: Sendable { case cancelled } - private let storage = Storage() + private let storage: Storage + public let waiter: Waiter - public init() {} + 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() async { + public func open() { let storage = storage let waiters = storage.state.withLock { state -> [CheckedContinuation] in guard !state.isOpen, !state.isCancelled else { return [] } state.isOpen = true - state.cancelledWaiterIDs.removeAll(keepingCapacity: false) let waiters = Array(state.waiters.values) state.waiters.removeAll(keepingCapacity: false) return waiters @@ -47,24 +82,14 @@ public final class WebInspectorTestGate: Sendable { } /// Cancels the gate and resumes every current and future waiter with cancellation. - public func cancel() async { + public func cancel() { Self.cancel(storage) } - /// Suspends until the gate opens or is cancelled. - /// - /// Cancellation is intentionally terminal for a test gate. Callers that - /// need to observe cancellation should use the module-internal throwing - /// operation used by the raw-wire driver. - public func wait() async { - _ = try? await Self.waitUntilOpen(storage) - } - - func waitUntilOpen() async throws { - try await Self.waitUntilOpen(storage) - } - - private static func waitUntilOpen(_ storage: Storage) async throws { + 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( @@ -72,13 +97,16 @@ public final class WebInspectorTestGate: Sendable { "WebInspectorTestGate exhausted its waiter identifier space." ) state.nextWaiterID += 1 - return state.nextWaiterID + 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.cancelledWaiterIDs.remove(waiterID) != nil || state.isCancelled { + if state.registeringWaiterIDs.remove(waiterID) == nil || state.isCancelled { return .cancelled } if state.isOpen { @@ -98,10 +126,11 @@ public final class WebInspectorTestGate: Sendable { } } 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 { - if !state.isOpen, !state.isCancelled { - state.cancelledWaiterIDs.insert(waiterID) - } + state.registeringWaiterIDs.remove(waiterID) return nil } return waiter @@ -116,7 +145,7 @@ public final class WebInspectorTestGate: Sendable { return [] } state.isCancelled = true - state.cancelledWaiterIDs.removeAll(keepingCapacity: false) + state.registeringWaiterIDs.removeAll(keepingCapacity: false) let waiters = Array(state.waiters.values) state.waiters.removeAll(keepingCapacity: false) return waiters diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index 2a54d62f..dfffcc00 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -899,23 +899,23 @@ struct DOMContainerTests { 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"]) } @@ -928,9 +928,13 @@ struct DOMContainerTests { 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) } @@ -944,13 +948,17 @@ struct DOMContainerTests { 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)) } @@ -964,12 +972,12 @@ struct DOMContainerTests { 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,7 +990,7 @@ struct DOMContainerTests { navigationItems.redoForTesting(undoManager: undoManager) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo"]) } @@ -995,22 +1003,20 @@ struct DOMContainerTests { 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,7 +1026,7 @@ struct DOMContainerTests { navigationItems.redoForTesting(undoManager: undoManager) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo"]) } @@ -1033,19 +1039,23 @@ struct DOMContainerTests { 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 +1072,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: " LiveDOMContextFixture { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - await enqueueLiveStartupReplies(on: runtime.backend, document: document ?? documentNode()) + let wire = WebInspectorRawWireDriver(peer: runtime.peer) + await wire.start() + try await enqueueLiveStartupReplies(on: wire, 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) + return LiveDOMContextFixture(runtime: runtime, wire: wire, 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 + 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) + await wire.respond(to: "Inspector.enable") + await wire.respond(to: "Inspector.initialized") + await wire.respond(to: "Runtime.enable") + await wire.respond(to: "Network.enable") + await wire.respond( + to: "DOM.getDocument", + with: try webInspectorDOMDocumentResult(document) + ) + await wire.respond(to: "Console.enable") + await wire.fail( + "CSS.getMatchedStylesForNode", + message: "Styles are intentionally unavailable in the DOM navigation fixture." + ) } private func waitForAttachedState(in context: WebInspectorContext) async -> Bool { @@ -1420,16 +1439,16 @@ 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 { @@ -1943,14 +1962,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/DOMTreeTextViewTests.swift b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift index 119d921b..abac8644 100644 --- a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift @@ -1311,21 +1311,23 @@ private final class ControlledNodeActionRecorder { case cancellationAware(WebInspectorTestGate) case cancellationIgnoring(CancellationIgnoringGate) + @MainActor func wait() async { switch self { case let .cancellationAware(gate): - await gate.wait() + await gate.waiter.wait() case let .cancellationIgnoring(gate): await gate.wait() } } - func open() async { + @MainActor + func open() { switch self { case let .cancellationAware(gate): - await gate.open() + gate.open() case let .cancellationIgnoring(gate): - await gate.open() + gate.open() } } } @@ -1383,7 +1385,7 @@ private final class ControlledNodeActionRecorder { if case .failure = resolution { failedInvocationIndexes.insert(index) } - await gates[index].open() + gates[index].open() } var invocationCount: Int { diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index d295edbf..307fe196 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 @@ -151,14 +152,14 @@ struct ParentContainerTests { let firstAttach = Task { @MainActor in try await session.attachForTesting( makeContainer: { [self] in - await firstAttachStarted.open() - await firstAttachGate.wait() + firstAttachStarted.open() + await firstAttachGate.waiter.wait() return try await makeFakeContainer() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) } - await firstAttachStarted.wait() + await firstAttachStarted.waiter.wait() var secondContext: WebInspectorContext? try await session.attachForTesting( @@ -171,7 +172,7 @@ struct ParentContainerTests { ) let installedSecondContext = try #require(secondContext) - await firstAttachGate.open() + firstAttachGate.open() do { try await firstAttach.value Issue.record("Expected superseded attach to be cancelled.") @@ -195,19 +196,19 @@ struct ParentContainerTests { let attachTask = Task { @MainActor in try await session.attachForTesting( makeContainer: { [self] in - await attachStarted.open() - await attachGate.wait() + attachStarted.open() + await attachGate.waiter.wait() return try await makeFakeContainer() }, 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.") From e106d3742e23590d705d648964d3c47c5bcafbd2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:49:02 +0900 Subject: [PATCH 45/81] test(proxy): adopt external gate wait handles --- .../ReplyPromiseTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift b/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift index a97ccf3d..b56b237a 100644 --- a/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift +++ b/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift @@ -77,7 +77,7 @@ func replyPromiseUnresolvedWaitObservesPreexistingCancellation() async throws { let promise = ReplyPromise() let startGate = WebInspectorTestGate() let waiter = Task { - await startGate.wait() + await startGate.waiter.wait() return try await promise.value() } @@ -95,7 +95,7 @@ func replyPromiseCleanupWaitIgnoresCallerCancellationUntilTerminal() async throw let promise = ReplyPromise() let startGate = WebInspectorTestGate() let waiter = Task { - await startGate.wait() + await startGate.waiter.wait() return try await promise.valueIgnoringCancellation() } @@ -136,7 +136,7 @@ func replyPromiseTerminalResultWinsCancellationAfterFulfillment() async throws { #expect(promise.fulfill(.success(47))) let waiter = Task { - await startGate.wait() + await startGate.waiter.wait() return try await promise.value() } waiter.cancel() @@ -156,14 +156,14 @@ func replyPromiseCancellationAndFulfillmentRaceResumesExactlyOnce() async throws #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) let cancellation = Task { - await raceGate.wait() + await raceGate.waiter.wait() waiter.cancel() } let fulfillment = Task { - await raceGate.wait() + await raceGate.waiter.wait() return promise.fulfill(.success(value)) } - await raceGate.open() + raceGate.open() await cancellation.value #expect(await fulfillment.value) From 5daf026f77e354e7e87595ddf5bca864f4a07282 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:55:13 +0900 Subject: [PATCH 46/81] fix(datakit): let WebKit release console object groups --- .../ConsoleMessageStore.swift | 15 ++--- .../WebInspectorContext.swift | 54 +--------------- .../ConsoleMessageStoreTests.swift | 1 - .../WebInspectorDataKitTests.swift | 63 +++---------------- 4 files changed, 15 insertions(+), 118 deletions(-) diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift index 0acf5756..dcb7f290 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -2,9 +2,9 @@ import WebInspectorProxyKit /// Owns Console message identity, order, query projection, and publication. /// -/// Runtime-object membership and protocol commands remain in -/// `WebInspectorContext`; clear operations return explicit effects for that -/// owner to apply. The store never retains an actor token or starts a task. +/// Runtime-object membership remains in `WebInspectorContext`; clear +/// operations return explicit ownership effects for that owner to apply. The +/// store never retains an actor token or starts a task. package final class ConsoleMessageStore { private enum ConcreteQueryBufferDestination { case candidate @@ -25,15 +25,9 @@ package final class ConsoleMessageStore { var committing: [UInt64: PendingConcreteQuery] } - package enum RuntimeObjectGroupRelease: Equatable { - case currentPage - case target(WebInspectorTarget.ID) - } - package struct Effects { package var runtimeObjectsToUnregister: [RuntimeObject] = [] package var clearedAllMessages = false - package var runtimeObjectGroupRelease: RuntimeObjectGroupRelease? } package struct QueryIndexReset: Sendable { @@ -446,8 +440,7 @@ package final class ConsoleMessageStore { await finishQueryIndexReset(reset, isolation: isolation) return Effects( runtimeObjectsToUnregister: runtimeObjectsToUnregister, - clearedAllMessages: targetID == nil, - runtimeObjectGroupRelease: targetID.map(RuntimeObjectGroupRelease.target) ?? .currentPage + clearedAllMessages: targetID == nil ) } diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift index 90a01923..81327333 100644 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorContext.swift @@ -155,7 +155,6 @@ public final class WebInspectorContext { private let statusRelay: WebInspectorAsyncStreamRelay private let networkRequests: NetworkRequestStore private let consoleMessages: ConsoleMessageStore - private var consoleObjectGroupReleaseTasks: [WebInspectorTarget.ID: Task] /// Creates a context owned by the supplied actor. public init(_ container: WebInspectorContainer, isolation: isolated (any Actor)) { @@ -192,7 +191,6 @@ public final class WebInspectorContext { statusRelay = WebInspectorAsyncStreamRelay() networkRequests = NetworkRequestStore() consoleMessages = ConsoleMessageStore() - consoleObjectGroupReleaseTasks = [:] WebInspectorDataKitLog.debug("context state=\(state.logDescription)") } @@ -222,9 +220,6 @@ public final class WebInspectorContext { task.cancel() } stopEventPumps() - for task in consoleObjectGroupReleaseTasks.values { - task.cancel() - } resolveEventPumpAppliedWaitersForTesting(result: false) } @@ -1079,7 +1074,6 @@ public final class WebInspectorContext { } styleToggleTasks = [:] stopEventPumps() - cancelConsoleObjectGroupReleaseTasks() currentPage = nil advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) @@ -1800,7 +1794,6 @@ extension WebInspectorContext { documentReloadTask = nil let generation = advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) - cancelConsoleObjectGroupReleaseTasks() for task in styleToggleTasks.values { task.cancel() } @@ -1941,7 +1934,6 @@ extension WebInspectorContext { task.cancel() } styleToggleTasks = [:] - cancelConsoleObjectGroupReleaseTasks() let generation = advanceCurrentPageGeneration(isolation: isolation) domState.advanceDocumentEpoch(isolation: isolation) let streamReset = prepareCurrentPageStreamReset(isolation: isolation) @@ -2683,15 +2675,10 @@ extension WebInspectorContext { }, isolation: isolation ) + // 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, isolation: isolation) - switch effects.runtimeObjectGroupRelease { - case .currentPage: - releaseConsoleRuntimeObjectGroup(isolation: isolation) - case let .target(targetID): - releaseConsoleRuntimeObjectGroup(targetID: targetID, isolation: isolation) - case nil: - break - } } private func applyConsoleMessageEffects( @@ -2708,41 +2695,6 @@ extension WebInspectorContext { ) } - 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 = [:] - } } extension WebInspectorContext { diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index 7d43e79f..62742bdb 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -258,7 +258,6 @@ func consoleMessageStoreOwnsTargetOrderAndClearEffects() async throws { ) #expect(effects.clearedAllMessages == false) - #expect(effects.runtimeObjectGroupRelease == .target(firstTargetID)) #expect(results.items.map(\.text) == ["second", "page"]) let clearedMessage = store.message(for: firstMessageID, isolation: MainActor.shared) #expect(clearedMessage == nil) diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index e1f07cab..1da882c0 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -1518,41 +1518,6 @@ func transportBackedFrameRuntimeAndConsoleEventsKeepTargetScope() async throws { #expect(consoleObject.proxyID?.unscopedRawValue == "frame-console-object") } -@MainActor -@Test -func consoleMessagesClearedForDistinctTargetsReleasesBothObjectGroups() async throws { - try await withDataKitTestRuntime { runtime in - let (_, context) = try await startContext(runtime: runtime) - _ = try await createFrameTarget( - in: runtime, - id: "console-frame-a", - frameID: "console-frame-a" - ) - _ = try await createFrameTarget( - in: runtime, - id: "console-frame-b", - frameID: "console-frame-b" - ) - await runtime.wire.respond(to: "Runtime.releaseObjectGroup") - await runtime.wire.respond(to: "Runtime.releaseObjectGroup") - - await context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-a") - ) - await context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-b") - ) - - try await waitUntil { - runtime.wire.observations.commands.filter { - $0.method == "Runtime.releaseObjectGroup" - }.count == 2 - } - } -} - @MainActor @Test func transportBackedStyleSheetTextEditRoutesToFrameTargetAndMarksUndo() async throws { @@ -4479,7 +4444,6 @@ func fetchedResultsPublishConsoleInsertUpdateAndDeleteTransactions() async throw let secondID = try #require(results.snapshot.itemIDs.last) #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1))]) - await runtime.wire.respond(to: "Runtime.releaseObjectGroup") try await runtime.wire.emitRaw( .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), target: target @@ -4491,10 +4455,6 @@ func fetchedResultsPublishConsoleInsertUpdateAndDeleteTransactions() async throw .delete(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), ]) #expect(results.snapshot.itemIDs == []) - _ = await runtime.wire.observations.waitForCompletedCommands( - method: "Runtime.releaseObjectGroup", - count: 1 - ) } } @@ -6972,17 +6932,12 @@ func consoleEventsPopulateRepeatAndClearFetchedMessages() async throws { try await waitUntil { results.items.count == 2 } #expect(results.items.map(\.text) == ["hello", "second"]) - await runtime.wire.respond(to: "Runtime.releaseObjectGroup") try await runtime.wire.emitRaw( .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 { - runtime.wire.observations.commands - .contains { $0.method == "Runtime.releaseObjectGroup" } - } } } @@ -7100,12 +7055,13 @@ func consoleMessageParametersRegisterRuntimeObjects() async throws { @MainActor @Test -func consoleMessagesClearedReleasesConsoleRuntimeObjects() async throws { +func consoleMessagesClearedInvalidatesRuntimeObjectsWithoutRuntimeCommand() async throws { try await withDataKitTestRuntime { runtime in let (target, context) = try await startContext(runtime: runtime) let objectID = Runtime.RemoteObject.ID("console-stale-object") let results: WebInspectorFetchedResults = context.fetchedResults() + let messageEventBaseline = context.eventPumpAppliedSequenceForTesting try await runtime.wire.emitRaw( .messageAdded(Console.Message( source: Console.Source(rawValue: "console-api"), @@ -7117,24 +7073,19 @@ func consoleMessagesClearedReleasesConsoleRuntimeObjects() async throws { )), target: target ) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: messageEventBaseline)) try await waitUntil { results.items.count == 1 } let message = try #require(results.items.first) let parameter = try #require(message.parameters.first) - await runtime.wire.respond(to: "Runtime.releaseObjectGroup") + let commandCountBeforeClear = runtime.wire.observations.commands.count + let eventBaseline = context.eventPumpAppliedSequenceForTesting try await runtime.wire.emitRaw( .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), target: target ) - try await waitUntil { - runtime.wire.observations.commands - .contains { $0.method == "Runtime.releaseObjectGroup" } - } - let releaseObjectGroupCommands = runtime.wire.observations.commands.filter { - $0.method == "Runtime.releaseObjectGroup" - } - #expect(releaseObjectGroupCommands.count == 1) + #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) try await waitUntil { results.items.isEmpty } do { _ = try await parameter.properties() @@ -7142,6 +7093,8 @@ func consoleMessagesClearedReleasesConsoleRuntimeObjects() async throws { } catch let error as WebInspectorProxyError { #expect(error == .disconnected("RuntimeObject is not registered in this WebInspectorContext.")) } + let clearCommands = runtime.wire.observations.commands.dropFirst(commandCountBeforeClear) + #expect(!clearCommands.contains { $0.method == "Runtime.releaseObjectGroup" }) #expect(context.state == .attached) } } From c97ef50a0fac863cc13d768364d2276069cb0d29 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:56:21 +0900 Subject: [PATCH 47/81] fix(proxy): preserve model feed boundary semantics --- Docs/WebInspectorKitsArchitecture.md | 16 +- .../Transport/ConnectionModelFeed.swift | 14 + .../Transport/TransportSession.swift | 128 ++++-- .../ConnectionModelFeedTests.swift | 423 ++++++++++++++++-- 4 files changed, 515 insertions(+), 66 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index b73ce9b0..2d44559f 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -639,6 +639,10 @@ 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, overflow, and consumer-lifecycle cases omitted +} ``` The current transport slice implements the bounded exclusive feed, initial @@ -657,6 +661,13 @@ capability await, and `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 @@ -783,7 +794,10 @@ 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. Required-target command failure is terminal. Bootstrap commands are +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. diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index b800c584..11b049f7 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -157,6 +157,17 @@ package enum ConnectionModelFeedRecord: Sendable { 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, @@ -201,6 +212,9 @@ package enum ConnectionModelFeedDeliveryResult: Sendable { 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 bufferOverflow(capacity: Int) case consumerTerminated } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 6398b79f..b4707e78 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -566,32 +566,36 @@ package actor ConnectionCore { do { for domain in ModelDomain.ordered(configuredDomains) { - let leaseOwner = ConnectionCapabilityLeaseOwner.modelFeed(id, domain) - for capabilityDomain in domain.capabilityDependencies { - try Task.checkCancellation() - guard isOpen else { - throw terminalScopeError + do { + let leaseOwner = ConnectionCapabilityLeaseOwner.modelFeed(id, domain) + for capabilityDomain in domain.capabilityDependencies { + 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 + ) } - 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() @@ -2195,6 +2199,15 @@ package actor ConnectionCore { 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)" )) @@ -2256,12 +2269,12 @@ package actor ConnectionCore { ?? event.sourceTargetID ?? targetRegistry.currentMainPageTargetID guard let targetID, - let target = targetRegistry.target(for: targetID), - targetRegistry.isCurrentPageModelTarget(target) else { + let targetRecord = targetRegistry.target(for: targetID), + targetRegistry.isCurrentPageModelTarget(targetRecord) else { return ConnectionCommandInvalidationEffects() } let oldEpoch = modelDocumentEpoch(for: targetID) - _ = advanceModelDocumentEpoch(for: targetID) + let documentEpoch = advanceModelDocumentEpoch(for: targetID) if var registration = modelFeed, var bootstrap = registration.domBootstrap, @@ -2297,6 +2310,24 @@ package actor ConnectionCore { effects.pendingFailures.append(contentsOf: directReplies.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 } @@ -2901,6 +2932,24 @@ package actor ConnectionCore { } } + /// 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 + ) + } + private nonisolated static func enableFailureProvesInactive( _ error: any Swift.Error ) -> Bool { @@ -4258,9 +4307,12 @@ package actor ConnectionCore { destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget ) // WebKit invalidates every bound node identifier before emitting - // documentUpdated. Advance the target epoch before this event can be - // projected into the ordered model feed. + // 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() @@ -4390,6 +4442,12 @@ package actor ConnectionCore { // 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) guard event.domain == .page @@ -4676,7 +4734,15 @@ package actor ConnectionCore { let scopeError: WebInspectorProxyError? = cause == .explicitClose ? nil : terminalScopeError if let scopeError { eventScopes.finishSubscribers(with: scopeError) - modelFeed?.mailbox.poison(throwing: 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] = [] diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 2fd01bcf..70b438f2 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -513,6 +513,50 @@ func modelFeedOverflowPoisonsFeedAndConnectionWithoutSilentDrop() async throws { } } +@Test +func domDocumentInvalidationOverflowIsFirstTerminalAndDoesNotGuessResync() 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], capacity: 3) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + let initialDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + await modelFeedRespondWithDocument(to: initialDocument, core: core, nodeID: "initial") + _ = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + for nodeID in 1...3 { + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":\#(nodeID),"childNodeCount":0}}"# + )) + } + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + )) + + let expectedError = ConnectionModelFeedError.bufferOverflow(capacity: 3) + #expect(await core.terminalCause == .modelFeedFailure(expectedError)) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + await #expect(throws: expectedError) { + try await iterator.next() + } + #expect(try await modelFeedDOMGetDocumentMessages(backend).count == 1) + #expect(await backend.isDetached()) + _ = feed +} + @Test func modelFeedAndDirectConsumersClaimConnectionExclusivelyInBothOrders() async throws { let directFirstCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) @@ -972,8 +1016,8 @@ func rejectedEnableDoesNotPublishReplayMarkerOrPoisonFullFeed() async throws { errorMessage: "enable rejected" ) - await #expect(throws: WebInspectorProxyError.commandRejected( - method: "Network.enable", + await #expect(throws: ConnectionModelFeedError.bootstrapFailed( + domain: .network, message: "enable rejected" )) { try await openTask.value @@ -989,6 +1033,61 @@ func rejectedEnableDoesNotPublishReplayMarkerOrPoisonFullFeed() async throws { 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], capacity: 8) + } + #expect(await core.terminalCause == nil) + #expect(await backend.isDetached() == false) + + await backend.setSendError(nil) + let replacement = try await core.openModelFeed(configuredDomains: [], capacity: 3) + 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], capacity: 8) + 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() @@ -1045,6 +1144,12 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( )) let configuredDomains = Set(ModelDomain.acquisitionOrder) let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) + let enableModelDomains: [ModelDomain] = [ + .css, + .network, + .console, + .runtime, + ] let openTask = Task { try await core.openModelFeed( configuredDomains: configuredDomains, @@ -1083,8 +1188,8 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( _ = try await openTask.value Issue.record("Expected configured capability acquisition to fail.") } catch { - #expect(error as? WebInspectorProxyError == .commandRejected( - method: enableMethods[failureIndex], + #expect(error as? ConnectionModelFeedError == .bootstrapFailed( + domain: enableModelDomains[failureIndex], message: "rejected-\(failureIndex)" )) } @@ -1449,7 +1554,7 @@ func domBootstrapRequestsMainAndCommittedFramesInSnapshotOrder() async throws { } @Test -func domDocumentEpochRetriesStaleReplyAndResynchronizesWithoutDuplicateSync() async throws { +func domDocumentInvalidationPrecedesMainTargetDeltasAndFreshBootstrap() async throws { let backend = FakeTransportBackend() let core = ConnectionCore(backend: backend, responseTimeout: nil) _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( @@ -1458,28 +1563,75 @@ func domDocumentEpochRetriesStaleReplyAndResynchronizesWithoutDuplicateSync() as frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 16) + let openTask = Task { + try await core.openModelFeed(configuredDomains: [.css], capacity: 24) + } let staleDocument = try await backend.waitForTargetMessage( method: "DOM.getDocument", ordinal: 0 ) + 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 firstUpdateSequence = await core.receiveRootMessage( + 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":{}}"# ) ) - let firstUpdate = try await modelFeedRequireEvent(iterator.next()) - #expect(firstUpdate.sequence == firstUpdateSequence) - guard case let .dom(_, event) = firstUpdate.payload, - case .documentUpdated = event else { - Issue.record("Expected the first DOM.documentUpdated event.") + 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( @@ -1491,36 +1643,147 @@ func domDocumentEpochRetriesStaleReplyAndResynchronizesWithoutDuplicateSync() as 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 >= firstUpdate.sequence) + #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) - let secondUpdateSequence = await core.receiveRootMessage( + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["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], capacity: 24) + 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: "page-main", + targetID: "frame-a", message: #"{"method":"DOM.documentUpdated","params":{}}"# ) ) - let secondUpdate = try await modelFeedRequireEvent(iterator.next()) - #expect(secondUpdate.sequence == secondUpdateSequence) - let replacementDocument = try await backend.waitForTargetMessage( + 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 ) - await modelFeedRespondWithDocument(to: replacementDocument, core: core, nodeID: "replacement") - let replacementBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) - let replacementCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) - #expect(replacementBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 2)) - #expect(replacementBootstrap.root.id == DOM.Node.ID("replacement")) - #expect(replacementCompletion.through == replacementBootstrap.sequence) + #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() - // A post-synchronization epoch refresh gets a bootstrap boundary, but the - // binding generation remains synchronized exactly once. #expect(try await iterator.next() == nil) await core.close() } @@ -1876,15 +2139,68 @@ func requiredDOMBootstrapFailureTerminatesTheFeed() async throws { errorMessage: "document rejected" ) + let expectedError = ConnectionModelFeedError.bootstrapFailed( + domain: .dom, + message: "document rejected" + ) await #expect(throws: WebInspectorProxyError.self) { try await core.waitUntilClosed() } - guard case let .fatal(message) = await core.terminalCause else { - Issue.record("Expected a required bootstrap failure to terminate the connection.") - return + #expect(await core.terminalCause == .modelFeedFailure(expectedError)) + await #expect(throws: expectedError) { + try await iterator.next() } - #expect(message.contains("DOM.getDocument failed for required target page-main")) + #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()) @@ -2032,8 +2348,8 @@ func rollingBackModelFeedDoesNotReadmitDOMBootstrapDuringCommit() async throws { _ = await core.receiveRootMessage( #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# ) - await #expect(throws: WebInspectorProxyError.commandRejected( - method: "Console.enable", + await #expect(throws: ConnectionModelFeedError.bootstrapFailed( + domain: .console, message: "console rejected" )) { try await openTask.value @@ -2111,8 +2427,20 @@ func modelFeedRollbackDisableRejectionTerminatesInsteadOfReusingEnabledState() a errorMessage: "disable rejected" ) - await #expect(throws: WebInspectorScopeError.self) { - try await openTask.value + 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.") @@ -2947,6 +3275,13 @@ private struct ModelFeedTargetSnapshotRecord { 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 @@ -3000,6 +3335,26 @@ private func modelFeedRequireTargetSnapshot( ) } +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 { From a38ff91e43e7ef77a9c3625af8ee70cdbab47795 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:00:40 +0900 Subject: [PATCH 48/81] docs(migration): explain raw peer testing upgrade --- Docs/MIGRATION.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/Docs/MIGRATION.md b/Docs/MIGRATION.md index 201c56c7..a57151d6 100644 --- a/Docs/MIGRATION.md +++ b/Docs/MIGRATION.md @@ -3,6 +3,90 @@ 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. + +### 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. + ## v0.2.0 These notes apply when upgrading from `v0.1.5` or earlier to `v0.2.0`. From 38a3fc32d56cc2ea8c51fddef70acc09a7120440 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:07:54 +0900 Subject: [PATCH 49/81] test(ui): adopt external gate wait handles --- .../ParentContainerTests.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index 307fe196..d4d02d57 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -305,8 +305,8 @@ struct ParentContainerTests { let factoryStarted = WebInspectorTestGate() let factoryRelease = WebInspectorTestGate() let contentStore = PresentationContentStore { context in - await factoryStarted.open() - await factoryRelease.wait() + factoryStarted.open() + await factoryRelease.waiter.wait() return try await NetworkPanelModel.make(context: context) } let observation = withPortableContinuousObservation { _ in @@ -332,8 +332,8 @@ struct ParentContainerTests { #expect(resourceViewController.contentUnavailableConfiguration != nil) #expect(contentStore.networkResourceStatus == .loading) - await factoryStarted.wait() - await factoryRelease.open() + await factoryStarted.waiter.wait() + factoryRelease.open() await contentStore.waitForNetworkResourceTaskForTesting() #expect(await statuses.waitUntilValue(.ready)) @@ -393,12 +393,12 @@ struct ParentContainerTests { let model = try await NetworkPanelModel.make(context: context) if context === firstContext { firstModel = model - await firstFactoryStarted.open() - await firstFactoryRelease.wait() + firstFactoryStarted.open() + await firstFactoryRelease.waiter.wait() } else { #expect(context === secondContext) - await secondFactoryStarted.open() - await secondFactoryRelease.wait() + secondFactoryStarted.open() + await secondFactoryRelease.waiter.wait() } return model } @@ -409,7 +409,7 @@ struct ParentContainerTests { ) { _ in UIViewController() } - await firstFactoryStarted.wait() + await firstFactoryStarted.waiter.wait() let withheldFirstModel = try #require(firstModel) let secondResourceViewController = contentStore.networkViewController( @@ -423,14 +423,14 @@ struct ParentContainerTests { #expect(secondResourceViewController.phase == .loading) #expect(contentStore.contextEpoch == 2) - await firstFactoryRelease.open() - await secondFactoryStarted.wait() + firstFactoryRelease.open() + await secondFactoryStarted.waiter.wait() #expect(withheldFirstModel.isRetiredForTesting) #expect(contentStore.networkResourceStatus == .loading) #expect(secondResourceViewController.readyViewControllerForTesting == nil) - await secondFactoryRelease.open() + secondFactoryRelease.open() await contentStore.waitForNetworkResourceTaskForTesting() let secondModel = try #require(contentStore.networkPanelModelForTesting) @@ -479,8 +479,8 @@ struct ParentContainerTests { let factoryRelease = WebInspectorTestGate() var contentStore: PresentationContentStore? = PresentationContentStore { context in let model = try await NetworkPanelModel.make(context: context) - await factoryStarted.open() - await factoryRelease.wait() + factoryStarted.open() + await factoryRelease.waiter.wait() return model } weak var retainedStore = contentStore @@ -490,14 +490,14 @@ struct ParentContainerTests { ) { _ in UIViewController() } - await factoryStarted.wait() + await factoryStarted.waiter.wait() contentStore = nil #expect(retainedStore == nil) #expect(resourceViewController.phase == .loading) - await factoryRelease.open() + factoryRelease.open() #expect(resourceViewController.readyViewControllerForTesting == nil) } From 684690ebabab5f28f26c7187aa48f6b3ae9f20a8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:32:47 +0900 Subject: [PATCH 50/81] fix(proxy): make model command runners acyclic --- .../Transport/TransportSession.swift | 440 +++++++++--------- .../ConnectionModelFeedTests.swift | 79 ++++ 2 files changed, 291 insertions(+), 228 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index b4707e78..f8604ce9 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -43,11 +43,6 @@ private struct ConnectionDirectCommandAdmission: Sendable { let documentEpoch: ModelDocumentEpoch? } -private struct ConnectionModelCommandOperation: Sendable { - let id: UInt64 - let task: Task -} - private enum ConnectionModelCommandFailureOverride: Sendable { case staleIdentifier case notActive @@ -68,22 +63,79 @@ private enum ConnectionModelCommandFailureOverride: Sendable { } } -private struct ConnectionModelCommandTask: Sendable { +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 authorization: ConnectionModelCommandAuthorization - let domain: ProtocolDomain - var pendingReplyOwnership: ConnectionPendingReplyOwnership? - var failureOverride: ConnectionModelCommandFailureOverride? + let control: ConnectionModelCommandControl } -private struct ConnectionModelCommandReadinessWaiter: Sendable { - let id: UInt64 - let operationID: UInt64 +private struct ConnectionModelCommandTask: Sendable { + let control: ConnectionModelCommandControl let authorization: ConnectionModelCommandAuthorization let domain: ProtocolDomain let method: String let routing: ProtocolCommand.Routing - let continuation: CheckedContinuation + var pendingReplyOwnership: ConnectionPendingReplyOwnership? + var readinessSignal: ReplyPromise? } private enum ConnectionModelCommandReadiness: Sendable { @@ -91,6 +143,11 @@ private enum ConnectionModelCommandReadiness: Sendable { case waiting } +private enum ConnectionModelCommandStep: Sendable { + case waiting(ReplyPromise) + case ready(ConnectionOwnedCommandOperation) +} + private enum ConnectionPendingReplyFailureReason: Sendable { case staleIdentifier case missingTarget(ProtocolTarget.ID) @@ -328,8 +385,6 @@ package actor ConnectionCore { private var modelBootstrapTasks: [UInt64: ConnectionModelBootstrapTask] private var nextModelCommandOperationID: UInt64 private var modelCommandTasks: [UInt64: ConnectionModelCommandTask] - private var nextModelCommandReadinessWaiterID: UInt64 - private var modelCommandReadinessWaiters: [UInt64: ConnectionModelCommandReadinessWaiter] private var modelCommandOwnerCountWaiters: [( expectedCount: Int, continuation: CheckedContinuation @@ -390,8 +445,6 @@ package actor ConnectionCore { modelBootstrapTasks = [:] nextModelCommandOperationID = 0 modelCommandTasks = [:] - nextModelCommandReadinessWaiterID = 0 - modelCommandReadinessWaiters = [:] modelCommandOwnerCountWaiters = [] modelCommandReadinessCountWaiters = [] modelDocumentEpochs = [:] @@ -420,15 +473,10 @@ package actor ConnectionCore { eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) modelFeed?.mailbox.finish(throwing: WebInspectorProxyError.closed) modelFeed = nil - let readinessWaiters = modelCommandReadinessWaiters.values - modelCommandReadinessWaiters.removeAll() - for waiter in readinessWaiters { - waiter.continuation.resume(throwing: WebInspectorProxyError.closed) - } let modelTasks = Array(modelCommandTasks.values) modelCommandTasks.removeAll() for modelTask in modelTasks { - modelTask.task.cancel() + modelTask.control.failFromOwner(.terminal(.transportClosed)) if let ownership = modelTask.pendingReplyOwnership { _ = replyStore.removePendingReply(ownership.key) } @@ -897,45 +945,47 @@ package actor ConnectionCore { try await releaseCapabilityLease(.eventScope(id), for: key) } - package func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { + package nonisolated func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { try Task.checkCancellation() - guard isOpen else { - throw terminalTransportError - } switch command.authority { case .direct: - // 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) - } + return try await sendDirect(command) case let .modelFeed(authorization): - let operation = try beginModelCommand(command, authorization: authorization) + let operation = try await beginModelCommand(command, authorization: authorization) return try await withTaskCancellationHandler { try Task.checkCancellation() return try await operation.task.value } onCancel: { - Task { [weak self] in - await self?.cancelModelCommand(operation.id) - } + 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 @@ -961,6 +1011,7 @@ package actor ConnectionCore { _ command: ProtocolCommand, authorization: ConnectionModelCommandAuthorization ) throws -> ConnectionModelCommandOperation { + try Task.checkCancellation() try validateModelCommandAuthority( authorization, domain: command.domain, @@ -972,25 +1023,38 @@ package actor ConnectionCore { ) nextModelCommandOperationID += 1 let operationID = nextModelCommandOperationID - let task = Task { [weak self, command, authorization] () throws -> ProtocolCommand.Result in - guard let self else { - throw WebInspectorProxyError.closed - } + let control = ConnectionModelCommandControl() + let task = Task { [weak self, command, authorization, control] () throws -> ProtocolCommand.Result in do { - let result = try await self.executeModelCommand( - command, - authorization: authorization, - operationID: operationID - ) - await self.finishModelCommand(operationID) - return result + 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 resolvedError = await self.modelCommandFailure( + let fallback = control.resolve(.failure(error)) + let resolved = await self?.finishModelCommand( operationID, - fallback: error - ) - await self.finishModelCommand(operationID) - throw resolvedError + fallback: fallback + ) ?? fallback + return try resolved.get() } } precondition( @@ -998,49 +1062,56 @@ package actor ConnectionCore { "A model command operation identifier already has an owner." ) modelCommandTasks[operationID] = ConnectionModelCommandTask( - task: task, + control: control, authorization: authorization, domain: command.domain, + method: command.method, + routing: command.routing, pendingReplyOwnership: nil, - failureOverride: nil + readinessSignal: nil ) + control.install(task) resumeModelCommandOwnerCountWaitersIfNeeded() - return ConnectionModelCommandOperation(id: operationID, task: task) + return ConnectionModelCommandOperation(task: task, control: control) } - private func executeModelCommand( + private func prepareModelCommandStep( _ command: ProtocolCommand, authorization: ConnectionModelCommandAuthorization, operationID: UInt64 - ) async throws -> ProtocolCommand.Result { - while true { - try Task.checkCancellation() - switch try modelCommandReadiness( + ) 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, - domain: command.domain, - method: command.method, - routing: command.routing - ) { - case let .ready(targetID): - try Task.checkCancellation() - // There is intentionally no suspension between this final - // readiness check and pending-reply insertion. - let operation = try makeModelCommandOperation( - command, - targetID: targetID, - authorization: authorization, - operationID: operationID - ) - return try await operation.result() - case .waiting: - try await suspendModelCommandUntilReadinessChanges( - operationID: operationID, - authorization: authorization, - domain: command.domain, - method: command.method, - routing: command.routing - ) + 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) } } @@ -1147,91 +1218,33 @@ package actor ConnectionCore { return .ready(targetID: targetID) } - private func suspendModelCommandUntilReadinessChanges( - operationID: UInt64, - authorization: ConnectionModelCommandAuthorization, - domain: ProtocolDomain, - method: String, - routing: ProtocolCommand.Routing - ) async throws { - precondition( - nextModelCommandReadinessWaiterID < UInt64.max, - "A model command readiness waiter identifier exhausted UInt64." - ) - nextModelCommandReadinessWaiterID += 1 - let waiterID = nextModelCommandReadinessWaiterID - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { ( - continuation: CheckedContinuation - ) in - if Task.isCancelled { - continuation.resume(throwing: CancellationError()) - return - } - do { - switch try modelCommandReadiness( - authorization: authorization, - domain: domain, - method: method, - routing: routing - ) { - case .ready: - continuation.resume() - case .waiting: - modelCommandReadinessWaiters[waiterID] = ConnectionModelCommandReadinessWaiter( - id: waiterID, - operationID: operationID, - authorization: authorization, - domain: domain, - method: method, - routing: routing, - continuation: continuation - ) - resumeModelCommandOwnerCountWaitersIfNeeded() - } - } catch { - continuation.resume(throwing: error) - } - } - } onCancel: { - Task { [weak self] in - await self?.cancelModelCommandReadinessWaiter(waiterID) - } - } - } - private func reevaluateModelCommandReadinessWaiters() { - for waiterID in modelCommandReadinessWaiters.keys.sorted() { - guard let waiter = modelCommandReadinessWaiters[waiterID] else { + for operationID in modelCommandTasks.keys.sorted() { + guard var task = modelCommandTasks[operationID], + let signal = task.readinessSignal else { continue } do { guard case .ready = try modelCommandReadiness( - authorization: waiter.authorization, - domain: waiter.domain, - method: waiter.method, - routing: waiter.routing + authorization: task.authorization, + domain: task.domain, + method: task.method, + routing: task.routing ) else { continue } - modelCommandReadinessWaiters.removeValue(forKey: waiterID) - waiter.continuation.resume() + task.readinessSignal = nil + modelCommandTasks[operationID] = task + signal.fulfill(.success(())) } catch { - modelCommandReadinessWaiters.removeValue(forKey: waiterID) - waiter.continuation.resume(throwing: error) + task.readinessSignal = nil + modelCommandTasks[operationID] = task + signal.fulfill(.failure(error)) } } resumeModelCommandOwnerCountWaitersIfNeeded() } - private func cancelModelCommandReadinessWaiter(_ waiterID: UInt64) { - guard let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) else { - return - } - waiter.continuation.resume(throwing: CancellationError()) - resumeModelCommandOwnerCountWaitersIfNeeded() - } - private func makeModelCommandOperation( _ command: ProtocolCommand, targetID: ProtocolTarget.ID?, @@ -1323,16 +1336,12 @@ package actor ConnectionCore { ) } - private func modelCommandFailure( + private func finishModelCommand( _ operationID: UInt64, - fallback: any Swift.Error - ) -> any Swift.Error { - modelCommandTasks[operationID]?.failureOverride?.error ?? fallback - } - - private func finishModelCommand(_ operationID: UInt64) { + fallback: Result + ) -> Result { guard let task = modelCommandTasks.removeValue(forKey: operationID) else { - return + return fallback } if let ownership = task.pendingReplyOwnership, let pending = replyStore.removePendingReply(ownership.key) { @@ -1341,34 +1350,9 @@ package actor ConnectionCore { "A model command task attempted to remove a reply owned by another purpose." ) } - if let waiterID = modelCommandReadinessWaiters.first(where: { - $0.value.operationID == operationID - })?.key, - let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) { - waiter.continuation.resume(throwing: CancellationError()) - } - resumeModelCommandOwnerCountWaitersIfNeeded() - } - - private func cancelModelCommand(_ operationID: UInt64) async { - guard var commandTask = modelCommandTasks[operationID] else { - return - } - commandTask.failureOverride = .cancelled - modelCommandTasks[operationID] = commandTask - if let waiterID = modelCommandReadinessWaiters.first(where: { - $0.value.operationID == operationID - })?.key, - let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) { - waiter.continuation.resume(throwing: CancellationError()) - } - if let ownership = commandTask.pendingReplyOwnership, - let pending = replyStore.removePendingReply(ownership.key) { - precondition(pending.purpose == ownership.purpose) - pending.promise.fulfill(.failure(CancellationError())) - } - commandTask.task.cancel() + task.readinessSignal?.fulfill(.failure(CancellationError())) resumeModelCommandOwnerCountWaitersIfNeeded() + return task.control.resolve(fallback) } private func requiredModelDomain(for domain: ProtocolDomain) -> ModelDomain? { @@ -1433,18 +1417,11 @@ package actor ConnectionCore { shouldInvalidate(commandTask) else { continue } - commandTask.failureOverride = failureOverride + commandTask.control.failFromOwner(failureOverride) + let readinessSignal = commandTask.readinessSignal + commandTask.readinessSignal = nil modelCommandTasks[operationID] = commandTask - - let waiterIDs = modelCommandReadinessWaiters.compactMap { waiterID, waiter in - waiter.operationID == operationID ? waiterID : nil - } - for waiterID in waiterIDs { - guard let waiter = modelCommandReadinessWaiters.removeValue(forKey: waiterID) else { - continue - } - waiter.continuation.resume(throwing: failureOverride.error) - } + readinessSignal?.fulfill(.failure(failureOverride.error)) if let ownership = commandTask.pendingReplyOwnership, let pending = replyStore.removePendingReply(ownership.key) { @@ -1459,8 +1436,9 @@ package actor ConnectionCore { ) ) } - commandTask.task.cancel() - effects.modelCommandTasksToAwait.append(commandTask.task) + if let task = commandTask.control.task { + effects.modelCommandTasksToAwait.append(task) + } } resumeModelCommandOwnerCountWaitersIfNeeded() return effects @@ -1564,19 +1542,26 @@ package actor ConnectionCore { } } + 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 { - modelCommandReadinessWaiters.count + modelCommandTasks.values.count { $0.readinessSignal != nil } } package func waitForModelCommandReadinessWaiterCountForTesting( _ expectedCount: Int ) async { precondition(expectedCount >= 0) - guard modelCommandReadinessWaiters.count != expectedCount else { + guard modelCommandReadinessWaiterCountForTesting() != expectedCount else { return } await withCheckedContinuation { continuation in @@ -1599,7 +1584,7 @@ package actor ConnectionCore { } modelCommandOwnerCountWaiters = pending - let readinessCount = modelCommandReadinessWaiters.count + let readinessCount = modelCommandReadinessWaiterCountForTesting() var pendingReadiness: [( expectedCount: Int, continuation: CheckedContinuation @@ -4764,22 +4749,21 @@ package actor ConnectionCore { validateReplyOwnership(pending, key: key) } let pendingReplies = Array(pendingReplyRecords.values) - let readinessWaiters = modelCommandReadinessWaiters.values - modelCommandReadinessWaiters.removeAll() - for waiter in readinessWaiters { - waiter.continuation.resume(throwing: transportError) - } - resumeModelCommandOwnerCountWaitersIfNeeded() var runningModelCommandTasks: [Task] = [] for operationID in modelCommandTasks.keys.sorted() { guard var commandTask = modelCommandTasks[operationID] else { continue } - commandTask.failureOverride = .terminal(transportError) + commandTask.control.failFromOwner(.terminal(transportError)) + let readinessSignal = commandTask.readinessSignal + commandTask.readinessSignal = nil modelCommandTasks[operationID] = commandTask - commandTask.task.cancel() - runningModelCommandTasks.append(commandTask.task) + 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 { diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 70b438f2..a7b2d246 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -3076,6 +3076,85 @@ func terminalCloseDrainsPendingModelCommandTasks() async throws { #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], + capacity: 8 + ) + _ = 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: [], + capacity: 8 + ) + 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, From eb69e9e6cc7b1dc357f1f3854a6b6553b730af7e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:35:40 +0900 Subject: [PATCH 51/81] refactor!: make inspector models actor-confined BREAKING CHANGE: Replace WebInspectorContainer and WebInspectorContext with the stable WebInspectorModelContext API and scoped resource lifecycles. --- Package.swift | 3 +- Sources/WebInspectorDataKit/CSSStyles.swift | 14 +- .../WebInspectorDataKit/ConsoleMessage.swift | 6 +- .../ConsoleMessageIndex.swift | 100 - .../ConsoleMessageQuery.swift | 533 --- .../ConsoleMessageStore.swift | 563 ++- Sources/WebInspectorDataKit/DOMMutation.swift | 51 +- .../DOMMutationPolicy.swift | 17 + Sources/WebInspectorDataKit/DOMNode.swift | 48 +- .../WebInspectorDataKit/DOMStateStore.swift | 196 +- Sources/WebInspectorDataKit/Fetching.swift | 808 +--- .../WebInspectorDataKit/NetworkRequest.swift | 20 +- .../NetworkRequestIndex.swift | 100 - .../NetworkRequestQuery.swift | 563 --- .../NetworkRequestStore.swift | 836 +++-- .../WebInspectorDataKit/RuntimeObject.swift | 27 +- .../RuntimeObjectGroup.swift | 97 + .../RuntimeStateStore.swift | 126 +- .../WebInspectorContainer.swift | 68 - .../WebInspectorContext.swift | 2709 -------------- .../WebInspectorDataKitLog.swift | 12 +- .../WebInspectorDomainEnablement.swift | 366 -- .../WebInspectorEventPump.swift | 41 - .../WebInspectorModelContext.swift | 3274 +++++++++++++++++ .../WebInspectorMutationOptions.swift | 39 - .../WebInspectorPersistentModel.swift | 5 +- Sources/WebInspectorKit/WebInspectorKit.swift | 1 + Sources/WebInspectorProxyKit/DOM.swift | 80 +- .../ElementPickerWire.swift | 49 + Sources/WebInspectorProxyKit/Inspector.swift | 12 +- .../LiveWebInspectorProxyBackend.swift | 9 +- Sources/WebInspectorProxyKit/Runtime.swift | 17 +- .../ConnectionEventScopeRegistry.swift | 1 + .../Transport/ConnectionModelFeed.swift | 9 + .../Transport/TransportSession.swift | 687 +++- .../WebInspectorProxy.swift | 83 +- .../WebInspectorTarget.swift | 29 +- .../Containers/WebInspectorSession.swift | 112 +- .../WebInspectorViewController+Preview.swift | 2 +- Sources/WebInspectorUI/DOMTabController.swift | 8 +- .../WebInspectorUI/NetworkTabController.swift | 2 +- .../Tabs/PresentationContentStore.swift | 8 +- .../WebInspectorUI/Tabs/WebInspectorTab.swift | 18 +- .../DOMCompactNavigationController.swift | 6 +- .../DOMDeletionUndoRegistration.swift | 14 +- .../DOMNavigationItems.swift | 29 +- .../DOMPreviewFixtures.swift | 10 +- .../DOMSplitViewController.swift | 8 +- .../DOMElementViewController+Preview.swift | 2 +- .../Element/DOMElementViewController.swift | 4 +- .../WebInspectorUIDOM/Tree/DOMTreeMenu.swift | 4 +- .../Tree/DOMTreeTextView.swift | 4 +- .../Tree/DOMTreeViewController.swift | 57 +- .../NetworkPanelModel.swift | 21 +- .../NetworkPreviewFixtures.swift | 8 +- 55 files changed, 5439 insertions(+), 6477 deletions(-) create mode 100644 Sources/WebInspectorDataKit/DOMMutationPolicy.swift create mode 100644 Sources/WebInspectorDataKit/RuntimeObjectGroup.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorContainer.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorContext.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorDomainEnablement.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorEventPump.swift create mode 100644 Sources/WebInspectorDataKit/WebInspectorModelContext.swift delete mode 100644 Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift create mode 100644 Sources/WebInspectorProxyKit/ElementPickerWire.swift diff --git a/Package.swift b/Package.swift index 76852868..e31c52f3 100644 --- a/Package.swift +++ b/Package.swift @@ -156,7 +156,8 @@ let package = Package( .target( name: "WebInspectorKit", dependencies: [ - "WebInspectorUI" + "WebInspectorUI", + "WebInspectorDataKit" ], swiftSettings: strictSwiftSettings ), diff --git a/Sources/WebInspectorDataKit/CSSStyles.swift b/Sources/WebInspectorDataKit/CSSStyles.swift index 7feac9e7..a408ee6c 100644 --- a/Sources/WebInspectorDataKit/CSSStyles.swift +++ b/Sources/WebInspectorDataKit/CSSStyles.swift @@ -68,10 +68,10 @@ public final class CSSStyles: WebInspectorPersistentModel { /// Computed properties for the element. public private(set) var computedProperties: [CSSComputedProperty] - @ObservationIgnored weak var modelContext: WebInspectorContext? + @ObservationIgnored weak var modelContext: WebInspectorModelContext? @ObservationIgnored private var inspectorBaselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] - init(nodeID: DOMNode.ID, modelContext: WebInspectorContext) { + init(nodeID: DOMNode.ID, modelContext: WebInspectorModelContext) { id = ID(nodeID: nodeID) phase = .loading sections = [] @@ -143,6 +143,16 @@ public final class CSSStyles: WebInspectorPersistentModel { return SetStyleTextIntent(styleID: style.id, text: text) } + func contains(propertyID: CSSStyleProperty.ID) -> Bool { + sections.contains { section in + section.style.properties.contains { $0.id == propertyID } + } + } + + func contains(ruleID: CSSStyleRule.ID) -> Bool { + sections.contains { $0.rule?.id == ruleID } + } + func setDeclarationTextIntent(for propertyID: CSSStyleProperty.ID, text replacementText: String) -> SetStyleTextIntent? { guard phase == .loaded, let (sectionIndex, propertyIndex) = locateProperty(propertyID) else { 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 index 1a005311..d94c5874 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -1,12 +1,5 @@ import Foundation -package struct ConsoleResultSetDelta: Sendable { - package var sequence: UInt64 - package var snapshot: WebInspectorFetchedResultsSnapshot - package var transaction: WebInspectorFetchedResultsTransaction - package var reconfigureItemIDs: Set -} - /// Owns compact Console records and concrete query projections away from the /// model-context actor. package actor ConsoleMessageIndex { @@ -532,97 +525,4 @@ package actor ConsoleMessageIndex { }) } - // Legacy generic-descriptor projection retained only until the final query - // migration commit removes the generic surface. - package func delta( - plan: ConsoleMessageQueryPlan, - sectionBy: WebInspectorSectionDescriptor?, - oldSnapshot: WebInspectorFetchedResultsSnapshot, - changedSince sequence: UInt64 - ) -> ConsoleResultSetDelta { - precondition(plan.requiresModelQuery == false) - let indexSequence = lastAppliedSequence - let newSnapshot = legacySnapshot(plan: plan, sectionBy: sectionBy) - let oldItemIDs = Set(oldSnapshot.itemIDs) - let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in - oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence - }) - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - updatedItemIDs: updatedItemIDs - ) - return ConsoleResultSetDelta( - sequence: indexSequence, - snapshot: newSnapshot, - transaction: transaction, - reconfigureItemIDs: updatedItemIDs - ) - } - - private func legacySnapshot( - plan: ConsoleMessageQueryPlan, - sectionBy: WebInspectorSectionDescriptor? - ) -> WebInspectorFetchedResultsSnapshot { - let matchingRecords = legacyVisibleRecords(plan: plan) - guard matchingRecords.isEmpty == false else { - return WebInspectorFetchedResultsSnapshot() - } - guard let sectionBy else { - return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) - } - var sections: [( - id: WebInspectorFetchSectionID, - title: String?, - itemIDs: [ConsoleMessage.ID] - )] = [] - for record in matchingRecords { - let identity = legacySectionIdentity(for: record, sectionBy: sectionBy) - if let index = sections.firstIndex(where: { $0.id == identity.id }) { - sections[index].itemIDs.append(record.id) - } else { - sections.append((identity.id, identity.title, [record.id])) - } - } - return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in - WebInspectorFetchedResultsSnapshot.Section( - id: section.id, - title: section.title, - itemIDs: section.itemIDs - ) - }) - } - - private func legacyVisibleRecords(plan: ConsoleMessageQueryPlan) -> [ConsoleMessageRecord] { - var records = orderedIDs.compactMap { recordsByID[$0] }.filter { - plan.matches(record: $0) == true - } - if plan.sortComparators.isEmpty == false { - records.sort { plan.ordersBefore($0, $1) } - } - let lowerBound = min(plan.fetchOffset, records.count) - let upperBound = plan.fetchLimit.map { min(lowerBound + $0, records.count) } ?? records.count - return Array(records[lowerBound.. - ) -> (id: WebInspectorFetchSectionID, title: String?) { - let value: String? - switch sectionBy.key { - case .consoleSource: - value = record.sourceRawValue - case .consoleLevel: - value = record.levelRawValue - case .consoleKind: - value = record.kindRawValue - case .consoleURL: - value = record.url - case .networkMethod, .networkResourceType, .networkResourceCategory, .networkMIMEType: - preconditionFailure("Network section descriptors cannot be applied to ConsoleMessage results.") - } - let title = value ?? "" - return (WebInspectorFetchSectionID(rawValue: title), title) - } } diff --git a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift index 6d449ce1..b2280c2c 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageQuery.swift @@ -1,5 +1,4 @@ import Foundation -import WebInspectorProxyKit package struct ConsoleMessageRecordInput: Hashable, Sendable { package var id: ConsoleMessage.ID @@ -60,535 +59,3 @@ package struct ConsoleMessageRecord: Hashable, Sendable { self.init(input: ConsoleMessageRecordInput(message: message, orderIndex: orderIndex)) } } - -package struct ConsoleMessageQueryPlan: Sendable { - package typealias RecordPredicate = @Sendable (ConsoleMessageRecord) -> Bool - - package enum Filter: Sendable { - case record(RecordPredicate) - case model(Predicate) - - package func matches(record: ConsoleMessageRecord) -> Bool? { - switch self { - case let .record(predicate): - return predicate(record) - case .model: - return nil - } - } - - package func matches(record: ConsoleMessageRecord, message: ConsoleMessage) -> Bool { - switch self { - case let .record(predicate): - return predicate(record) - case let .model(predicate): - do { - return try predicate.evaluate(message) - } catch { - preconditionFailure("ConsoleMessage predicate evaluation failed: \(error)") - } - } - } - } - - package var filter: Filter? - package var sortComparators: [ConsoleMessageRecordSortComparator] - package var modelSortDescriptors: [SortDescriptor]? - package var fetchLimit: Int? - package var fetchOffset: Int - - package init(descriptor: WebInspectorFetchDescriptor) { - filter = descriptor.predicate.map(makeConsoleMessageFilter) - let recordSortComparators = descriptor.sortBy.compactMap { - ConsoleMessageRecordSortComparator(descriptor: $0) - } - if recordSortComparators.count == descriptor.sortBy.count { - sortComparators = recordSortComparators - modelSortDescriptors = nil - } else { - sortComparators = [] - modelSortDescriptors = descriptor.sortBy - } - fetchLimit = descriptor.fetchLimit - fetchOffset = descriptor.fetchOffset - } - - package var requiresQuery: Bool { - filter != nil - || sortComparators.isEmpty == false - || modelSortDescriptors?.isEmpty == false - || fetchLimit != nil - || fetchOffset > 0 - } - - package var requiresModelPredicate: Bool { - guard case .model = filter else { - return false - } - return true - } - - package var requiresModelQuery: Bool { - requiresModelPredicate || modelSortDescriptors != nil - } - - package func matches(record: ConsoleMessageRecord) -> Bool? { - filter?.matches(record: record) ?? true - } - - package func matches(record: ConsoleMessageRecord, message: ConsoleMessage) -> Bool { - filter?.matches(record: record, message: message) ?? true - } - - package func visibleIDs(from matchingIDs: [ConsoleMessage.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 { - precondition(modelSortDescriptors == nil) - for comparator in sortComparators { - switch comparator.compare(lhs, rhs) { - case .orderedAscending: - return true - case .orderedDescending: - return false - case .orderedSame: - continue - } - } - return lhs.orderIndex < rhs.orderIndex - } - - package func ordersBefore( - _ lhs: ConsoleMessageRecord, - message lhsMessage: ConsoleMessage, - _ rhs: ConsoleMessageRecord, - message rhsMessage: ConsoleMessage - ) -> Bool { - guard let modelSortDescriptors else { - return ordersBefore(lhs, rhs) - } - for descriptor in modelSortDescriptors { - switch descriptor.compare(lhsMessage, rhsMessage) { - case .orderedAscending: - return true - case .orderedDescending: - return false - case .orderedSame: - continue - } - } - return lhs.orderIndex < rhs.orderIndex - } -} - -package struct ConsoleMessageQueryState { - package var plan: ConsoleMessageQueryPlan - private var recordsByID: [ConsoleMessage.ID: ConsoleMessageRecord] - private var messagesByID: [ConsoleMessage.ID: ConsoleMessage] - private var matchingIDs: [ConsoleMessage.ID] - - package init(plan: ConsoleMessageQueryPlan, messages: [ConsoleMessage]) { - self.plan = plan - recordsByID = [:] - recordsByID.reserveCapacity(messages.count) - messagesByID = [:] - messagesByID.reserveCapacity(messages.count) - matchingIDs = [] - matchingIDs.reserveCapacity(messages.count) - for (index, message) in messages.enumerated() { - upsert(message: message, orderIndex: index) - } - } - - private mutating func upsert(message: ConsoleMessage, orderIndex: Int) { - let record = ConsoleMessageRecord(message: message, orderIndex: orderIndex) - recordsByID[record.id] = record - messagesByID[record.id] = message - matchingIDs.removeAll { $0 == record.id } - guard plan.matches(record: record, message: message) else { - return - } - insertMatchingID(record.id) - } - - package mutating func upsert(message: ConsoleMessage) { - let orderIndex = recordsByID[message.id]?.orderIndex ?? recordsByID.count - upsert(message: message, orderIndex: orderIndex) - } - - package func visibleMessages( - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) -> [ConsoleMessage] { - plan.visibleIDs(from: matchingIDs).compactMap(lookup) - } - - private mutating func insertMatchingID(_ id: ConsoleMessage.ID) { - guard let record = recordsByID[id], let message = messagesByID[id] else { - preconditionFailure("Console query state lost the record or model for an inserted identity.") - } - var lowerBound = 0 - var upperBound = matchingIDs.count - while lowerBound < upperBound { - let midpoint = (lowerBound + upperBound) / 2 - let midpointID = matchingIDs[midpoint] - guard let midpointRecord = recordsByID[midpointID], - let midpointMessage = messagesByID[midpointID] else { - preconditionFailure("Console query state lost a matching record or model.") - } - if plan.ordersBefore( - midpointRecord, - message: midpointMessage, - record, - message: message - ) { - lowerBound = midpoint + 1 - } else { - upperBound = midpoint - } - } - matchingIDs.insert(id, at: lowerBound) - } -} - -package struct ConsoleMessageRecordSortComparator: Sendable { - private enum Key: Sendable { - case source - case level - case kind - case text - case url - case line - case column - case repeatCount - case timestamp - case id - } - - private var key: Key - private var order: SortOrder - - fileprivate init?(descriptor: SortDescriptor) { - guard let key = Self.key(for: descriptor) else { - return nil - } - self.key = key - order = descriptor.order - } - - fileprivate func compare( - _ lhs: ConsoleMessageRecord, - _ rhs: ConsoleMessageRecord - ) -> ComparisonResult { - let result: ComparisonResult - switch key { - case .source: - result = compareStrings(lhs.sourceRawValue, rhs.sourceRawValue) - case .level: - result = compareStrings(lhs.levelRawValue, rhs.levelRawValue) - case .kind: - result = compareOptionalStrings(lhs.kindRawValue, rhs.kindRawValue) - case .text: - result = compareStrings(lhs.text, rhs.text) - case .url: - result = compareOptionalStrings(lhs.url, rhs.url) - case .line: - result = compareOptional(lhs.line, rhs.line) - case .column: - result = compareOptional(lhs.column, rhs.column) - case .repeatCount: - result = compareValues(lhs.repeatCount, rhs.repeatCount) - case .timestamp: - result = compareOptional(lhs.timestamp, rhs.timestamp) - case .id: - result = compareValues(lhs.id, rhs.id) - } - switch order { - case .forward: - return result - case .reverse: - return result.reversedForConsoleQuery - } - } - - private static func key(for descriptor: SortDescriptor) -> Key? { - let order = descriptor.order - let candidates: [(Key, SortDescriptor)] = [ - (.source, SortDescriptor(\.source.rawValue, order: order)), - (.level, SortDescriptor(\.level.rawValue, order: order)), - (.kind, SortDescriptor(\.kind?.rawValue, order: order)), - (.text, SortDescriptor(\.text, order: order)), - (.url, SortDescriptor(\.url, order: order)), - (.line, SortDescriptor(\.line, order: order)), - (.column, SortDescriptor(\.column, order: order)), - (.repeatCount, SortDescriptor(\.repeatCount, order: order)), - (.timestamp, SortDescriptor(\.timestamp, order: order)), - (.id, SortDescriptor(\.id, order: order)), - ] - for (key, candidate) in candidates where descriptor == candidate { - return key - } - return nil - } - - private func compareStrings(_ lhs: String, _ rhs: String) -> ComparisonResult { - String.StandardComparator.localizedStandard.compare(lhs, rhs) - } - - private func compareOptionalStrings(_ lhs: String?, _ rhs: String?) -> ComparisonResult { - switch (lhs, rhs) { - case (nil, nil): - return .orderedSame - case (nil, _): - return .orderedAscending - case (_, nil): - return .orderedDescending - case let (lhs?, rhs?): - return compareStrings(lhs, rhs) - } - } - - private func compareValues(_ lhs: Value, _ rhs: Value) -> ComparisonResult { - if lhs < rhs { - return .orderedAscending - } - if lhs > rhs { - return .orderedDescending - } - return .orderedSame - } - - 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?): - return compareValues(lhs, rhs) - } - } -} - -private extension ComparisonResult { - var reversedForConsoleQuery: ComparisonResult { - switch self { - case .orderedAscending: - return .orderedDescending - case .orderedDescending: - return .orderedAscending - case .orderedSame: - return .orderedSame - } - } -} - -private protocol ConsoleMessageRecordPredicateExpression { - func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate -} - -private enum ConsoleMessageRecordPredicateValue: Equatable, Sendable { - case string(String) - case optionalString(String?) - case integer(Int) - case optionalInteger(Int?) -} - -private protocol ConsoleMessageRecordEquatableExpression { - func consoleMessageEquatableExpression() throws - -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue -} - -private protocol ConsoleMessageRecordStringExpression { - func consoleMessageStringExpression() throws -> @Sendable (ConsoleMessageRecord) -> String -} - -private protocol ConsoleMessageRecordLevelExpression { - func consoleMessageLevelExpression() throws -> @Sendable (ConsoleMessageRecord) -> Console.Level -} - -private struct UnsupportedConsoleMessageRecordPredicate: Error {} - -private func makeConsoleMessageFilter( - _ predicate: Predicate -) -> ConsoleMessageQueryPlan.Filter { - guard let expression = predicate.expression as? any ConsoleMessageRecordPredicateExpression else { - return .model(predicate) - } - do { - return .record(try expression.consoleMessageRecordPredicate()) - } catch is UnsupportedConsoleMessageRecordPredicate { - return .model(predicate) - } catch { - preconditionFailure("ConsoleMessage predicate planning failed: \(error)") - } -} - -extension PredicateExpressions.Conjunction: ConsoleMessageRecordPredicateExpression - where LHS: ConsoleMessageRecordPredicateExpression, RHS: ConsoleMessageRecordPredicateExpression -{ - fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { - let lhsPredicate = try lhs.consoleMessageRecordPredicate() - let rhsPredicate = try rhs.consoleMessageRecordPredicate() - return { record in - lhsPredicate(record) && rhsPredicate(record) - } - } -} - -extension PredicateExpressions.Disjunction: ConsoleMessageRecordPredicateExpression - where LHS: ConsoleMessageRecordPredicateExpression, RHS: ConsoleMessageRecordPredicateExpression -{ - fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { - let lhsPredicate = try lhs.consoleMessageRecordPredicate() - let rhsPredicate = try rhs.consoleMessageRecordPredicate() - return { record in - lhsPredicate(record) || rhsPredicate(record) - } - } -} - -extension PredicateExpressions.Equal: ConsoleMessageRecordPredicateExpression - where LHS: ConsoleMessageRecordEquatableExpression, RHS: ConsoleMessageRecordEquatableExpression -{ - fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { - let lhsExpression = try lhs.consoleMessageEquatableExpression() - let rhsExpression = try rhs.consoleMessageEquatableExpression() - return { record in - consoleMessagePredicateValuesEqual(lhsExpression(record), rhsExpression(record)) - } - } -} - -private func consoleMessagePredicateValuesEqual( - _ lhs: ConsoleMessageRecordPredicateValue, - _ rhs: ConsoleMessageRecordPredicateValue -) -> 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 (.integer(lhs), .integer(rhs)): - return lhs == rhs - case let (.optionalInteger(lhs), .optionalInteger(rhs)): - return lhs == rhs - case let (.optionalInteger(lhs), .integer(rhs)): - return lhs == rhs - case let (.integer(lhs), .optionalInteger(rhs)): - return lhs == rhs - default: - return false - } -} - -extension PredicateExpressions.StringLocalizedStandardContains: ConsoleMessageRecordPredicateExpression - where Root: ConsoleMessageRecordStringExpression, Other: ConsoleMessageRecordStringExpression -{ - fileprivate func consoleMessageRecordPredicate() throws -> ConsoleMessageQueryPlan.RecordPredicate { - let rootExpression = try root.consoleMessageStringExpression() - let otherExpression = try other.consoleMessageStringExpression() - return { record in - rootExpression(record).localizedStandardContains(otherExpression(record)) - } - } -} - -extension PredicateExpressions.KeyPath: ConsoleMessageRecordStringExpression { - fileprivate func consoleMessageStringExpression() throws - -> @Sendable (ConsoleMessageRecord) -> String { - let keyPath = keyPath as AnyKeyPath - if let levelRoot = root as? any ConsoleMessageRecordLevelExpression, - keyPath == (\Console.Level.rawValue as AnyKeyPath) { - let levelExpression = try levelRoot.consoleMessageLevelExpression() - return { levelExpression($0).rawValue } - } - if keyPath == (\ConsoleMessage.text as AnyKeyPath) { - return { $0.text } - } - throw UnsupportedConsoleMessageRecordPredicate() - } -} - -extension PredicateExpressions.KeyPath: ConsoleMessageRecordLevelExpression - where Root == PredicateExpressions.Variable, Output == Console.Level -{ - fileprivate func consoleMessageLevelExpression() throws - -> @Sendable (ConsoleMessageRecord) -> Console.Level { - guard keyPath == \ConsoleMessage.level else { - throw UnsupportedConsoleMessageRecordPredicate() - } - return { Console.Level(rawValue: $0.levelRawValue) } - } -} - -extension PredicateExpressions.KeyPath: ConsoleMessageRecordEquatableExpression { - fileprivate func consoleMessageEquatableExpression() throws - -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue { - if let stringExpression = try? consoleMessageStringExpression() { - return { .string(stringExpression($0)) } - } - let keyPath = keyPath as AnyKeyPath - if keyPath == (\ConsoleMessage.kind?.rawValue as AnyKeyPath) { - return { .optionalString($0.kindRawValue) } - } - if keyPath == (\ConsoleMessage.url as AnyKeyPath) { - return { .optionalString($0.url) } - } - if keyPath == (\ConsoleMessage.repeatCount as AnyKeyPath) { - return { .integer($0.repeatCount) } - } - if keyPath == (\ConsoleMessage.line as AnyKeyPath) { - return { .optionalInteger($0.line) } - } - if keyPath == (\ConsoleMessage.column as AnyKeyPath) { - return { .optionalInteger($0.column) } - } - throw UnsupportedConsoleMessageRecordPredicate() - } -} - -extension PredicateExpressions.Value: ConsoleMessageRecordStringExpression where Output == String { - fileprivate func consoleMessageStringExpression() throws - -> @Sendable (ConsoleMessageRecord) -> String { - let value = value - return { _ in value } - } -} - -extension PredicateExpressions.Value: ConsoleMessageRecordEquatableExpression { - fileprivate func consoleMessageEquatableExpression() throws - -> @Sendable (ConsoleMessageRecord) -> ConsoleMessageRecordPredicateValue { - 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? Int { - return { _ in .integer(value) } - } - if Output.self == Optional.self { - let value = value as! Int? - return { _ in .optionalInteger(value) } - } - throw UnsupportedConsoleMessageRecordPredicate() - } -} diff --git a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift index dcb7f290..4f4cc52a 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessageStore.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessageStore.swift @@ -2,10 +2,68 @@ import WebInspectorProxyKit /// Owns Console message identity, order, query projection, and publication. /// -/// Runtime-object membership remains in `WebInspectorContext`; clear +/// Runtime-object membership remains in `WebInspectorModelContext`; clear /// operations return explicit ownership effects for that owner to apply. The /// store never retains an actor token or starts a task. package final class ConsoleMessageStore { + package struct IndexWork: Sendable { + fileprivate enum Action: Sendable { + case replace( + inputs: [ConsoleMessageRecordInput], + sequence: UInt64, + sourceEpoch: UInt64 + ) + case upsert(input: ConsoleMessageRecordInput, sequence: UInt64) + } + + fileprivate let index: ConsoleMessageIndex + fileprivate let actions: [Action] + + package nonisolated(nonsending) func run() async -> 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 @@ -36,6 +94,11 @@ package final class ConsoleMessageStore { 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 @@ -54,7 +117,6 @@ package final class ConsoleMessageStore { private let queryIndex: ConsoleMessageIndex private var queryIndexSequence: UInt64 private var queryIndexNeedsRebuild: Bool - private var fetchedResults: [WeakWebInspectorFetchedResults] private var querySourceEpoch: UInt64 private var nextConcreteQueryRegistrationID: UInt64 private var initializingConcreteQueries: [ @@ -77,7 +139,6 @@ package final class ConsoleMessageStore { queryIndex = ConsoleMessageIndex() queryIndexSequence = 0 queryIndexNeedsRebuild = false - fetchedResults = [] querySourceEpoch = 0 nextConcreteQueryRegistrationID = 0 initializingConcreteQueries = [:] @@ -92,49 +153,23 @@ package final class ConsoleMessageStore { performanceCounters } - package func resetPerformanceCountersForTesting( - isolation: isolated (any Actor) = #isolation - ) { - _ = isolation + package func resetPerformanceCountersForTesting() { performanceCounters = PerformanceCounters() } #endif - package func message( - for id: ConsoleMessage.ID, - isolation: isolated (any Actor) = #isolation - ) -> ConsoleMessage? { - _ = isolation - return messagesByID[id] - } - - package func register( - _ results: WebInspectorFetchedResults, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation - ) { - let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor) - results.setConsoleItems( - currentMessages(isolation: isolation), - plan: plan, - indexSequence: queryIndexSequence, - lookup: { id in self.messagesByID[id] } - ) - fetchedResults.append(WeakWebInspectorFetchedResults(results)) + package func message(for id: ConsoleMessage.ID) -> ConsoleMessage? { + messagesByID[id] } - package func results( + package nonisolated(nonsending) func results( matching query: ConsoleQuery, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) async throws -> WebInspectorFetchedResults { - await syncQueryIndexIfNeeded(isolation: isolation) - let id = allocateConcreteQueryRegistrationID(isolation: isolation) + await syncQueryIndexIfNeeded() + let id = allocateConcreteQueryRegistrationID() let lifetime = WebInspectorQueryRegistrationLifetime() - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: modelContext - ) + let results = WebInspectorFetchedResults(modelContext: modelContext) results.installQueryRegistration(id: id, lifetime: lifetime) let generation = results.nextConcreteQueryGeneration() initializingConcreteQueries[id] = PendingConcreteQuery( @@ -173,7 +208,7 @@ package final class ConsoleMessageStore { query, generation: generation, projection: installedProjection, - lookup: { id in self.messageForResult(id, isolation: isolation) } + lookup: { id in self.messageForResult(id) } ) concreteQueryRegistrations[id] = ConcreteQueryRegistration( results: WeakWebInspectorFetchedResults(results), @@ -191,15 +226,14 @@ package final class ConsoleMessageStore { return results } - package func update( + package nonisolated(nonsending) func update( _ query: ConsoleQuery, - for results: WebInspectorFetchedResults, - isolation: isolated (any Actor) = #isolation + for results: WebInspectorFetchedResults ) async throws { guard let id = results.concreteQueryRegistrationID else { preconditionFailure("Console fetched results are not registered in this store.") } - await syncQueryIndexIfNeeded(isolation: isolation) + await syncQueryIndexIfNeeded() guard var registration = concreteQueryRegistrations[id], registration.results.value === results else { preconditionFailure("Console fetched results are not registered in this store.") @@ -270,7 +304,7 @@ package final class ConsoleMessageStore { query: query, generation: generation, isReplacement: true, - lookup: { id in self.messageForResult(id, isolation: isolation) } + lookup: { id in self.messageForResult(id) } ) if generation > afterCommit.activeGeneration { afterCommit.activeGeneration = generation @@ -287,28 +321,109 @@ package final class ConsoleMessageStore { } } - package func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - for results: WebInspectorFetchedResults, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation - ) { - let plan = ConsoleMessageQueryPlan(descriptor: descriptor) - results.applyConsoleFetchDescriptor( - descriptor, - plan: plan, - messages: currentMessages(isolation: isolation), - indexSequence: queryIndexSequence, - lookup: { id in self.messagesByID[id] } + package func indexWork(for reset: QueryIndexReset) -> IndexWork { + IndexWork( + index: queryIndex, + actions: [ + .replace( + inputs: reset.inputs, + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + ] ) } - package func apply( + 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: WebInspectorContext, - registerRuntimeObject: (Runtime.RemoteObject) -> RuntimeObject, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext, + registerRuntimeObject: (Runtime.RemoteObject) -> RuntimeObject ) async -> Effects { switch event { case let .messageAdded(payload): @@ -317,8 +432,7 @@ package final class ConsoleMessageStore { payload, parameters: parameters, targetID: targetID, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) return Effects() case let .messageRepeatCountUpdated(count, timestamp): @@ -326,58 +440,47 @@ package final class ConsoleMessageStore { count, timestamp: timestamp, targetID: targetID, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) return Effects() case .messagesCleared: - return await clear(targetID: targetID, modelContext: modelContext, isolation: isolation) + return await clear(targetID: targetID, modelContext: modelContext) case .unknown: return Effects() } } - package func resetForReplay( - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation - ) async { - _ = removeMessages(targetID: nil, isolation: isolation) - refreshAllResults(modelContext: modelContext, isolation: isolation) - let reset = prepareEmptyQueryIndexReset(isolation: isolation) - await finishQueryIndexReset(reset, isolation: isolation) + package nonisolated(nonsending) func resetForReplay(modelContext: WebInspectorModelContext) async { + _ = removeMessages(targetID: nil) + let reset = prepareEmptyQueryIndexReset() + await finishQueryIndexReset(reset) } package func prepareClearForLifecycle( - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) -> (effects: Effects, queryIndexReset: QueryIndexReset) { - _ = removeMessages(targetID: nil, isolation: isolation) - refreshAllResults(modelContext: modelContext, isolation: isolation) - let reset = prepareEmptyQueryIndexReset(isolation: isolation) + _ = removeMessages(targetID: nil) + let reset = prepareEmptyQueryIndexReset() return ( Effects(clearedAllMessages: true), reset ) } - package func finishQueryIndexReset( - _ reset: QueryIndexReset, - isolation: isolated (any Actor) = #isolation - ) async { + 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, isolation: isolation) + await applyConcreteDeliveries(deliveries) } - private func insertMessage( + private nonisolated(nonsending) func insertMessage( _ payload: Console.Message, parameters: [RuntimeObject], targetID: WebInspectorTarget.ID?, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { precondition(nextMessageOrdinal < Int.max, "ConsoleMessage identity ordinal overflowed.") let id = ConsoleMessage.ID(nextMessageOrdinal) @@ -396,15 +499,14 @@ package final class ConsoleMessageStore { if let targetID { lastMessageIDByTargetID[targetID] = id } - await notifyMessageInserted(message, modelContext: modelContext, isolation: isolation) + await notifyMessageInserted(message, modelContext: modelContext) } - private func updateRepeatCount( + private nonisolated(nonsending) func updateRepeatCount( _ count: Int, timestamp: Double?, targetID: WebInspectorTarget.ID?, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { let candidateID: ConsoleMessage.ID? if let targetID { @@ -418,26 +520,21 @@ package final class ConsoleMessageStore { return } message.updateRepeatCount(count, timestamp: timestamp) - await notifyMessageMutated(message, modelContext: modelContext, isolation: isolation) + await notifyMessageMutated(message, modelContext: modelContext) } - private func clear( + private nonisolated(nonsending) func clear( targetID: WebInspectorTarget.ID?, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async -> Effects { - let removedMessages = removeMessages(targetID: targetID, isolation: isolation) + let removedMessages = removeMessages(targetID: targetID) let runtimeObjectsToUnregister = targetID == nil ? [] - : unreferencedRuntimeObjects( - from: removedMessages, - isolation: isolation - ) - refreshAllResults(modelContext: modelContext, isolation: isolation) + : unreferencedRuntimeObjects(from: removedMessages) let reset = targetID == nil - ? prepareEmptyQueryIndexReset(isolation: isolation) - : prepareQueryIndexReset(isolation: isolation) - await finishQueryIndexReset(reset, isolation: isolation) + ? prepareEmptyQueryIndexReset() + : prepareQueryIndexReset() + await finishQueryIndexReset(reset) return Effects( runtimeObjectsToUnregister: runtimeObjectsToUnregister, clearedAllMessages: targetID == nil @@ -445,11 +542,7 @@ package final class ConsoleMessageStore { } @discardableResult - private func removeMessages( - targetID: WebInspectorTarget.ID?, - isolation: isolated (any Actor) - ) -> [ConsoleMessage] { - _ = isolation + private func removeMessages(targetID: WebInspectorTarget.ID?) -> [ConsoleMessage] { queryIndexNeedsRebuild = false guard let targetID else { let removedMessages = Array(messagesByID.values) @@ -472,7 +565,7 @@ package final class ConsoleMessageStore { orderIndicesByID[id] = nil } orderedMessageIDs.removeAll { removedIDs.contains($0) } - rebuildOrderIndices(isolation: isolation) + rebuildOrderIndices() if let lastMessageID, removedIDs.contains(lastMessageID) { self.lastMessageID = orderedMessageIDs.last } @@ -483,10 +576,8 @@ package final class ConsoleMessageStore { } private func unreferencedRuntimeObjects( - from removedMessages: [ConsoleMessage], - isolation: isolated (any Actor) + from removedMessages: [ConsoleMessage] ) -> [RuntimeObject] { - _ = isolation let removedObjectsByID = Dictionary( removedMessages.flatMap(\.parameters).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } @@ -500,8 +591,7 @@ package final class ConsoleMessageStore { } } - private func rebuildOrderIndices(isolation: isolated (any Actor)) { - _ = isolation + private func rebuildOrderIndices() { orderIndicesByID = Dictionary( uniqueKeysWithValues: orderedMessageIDs.enumerated().map { index, id in (id, index) @@ -509,18 +599,7 @@ package final class ConsoleMessageStore { ) } - private func currentMessages(isolation: isolated (any Actor)) -> [ConsoleMessage] { - _ = isolation -#if DEBUG - performanceCounters.fullModelProjectionCount += orderedMessageIDs.count -#endif - return orderedMessageIDs.compactMap { messagesByID[$0] } - } - - private func currentRecordInputs( - isolation: isolated (any Actor) - ) -> [ConsoleMessageRecordInput] { - _ = isolation + private func currentRecordInputs() -> [ConsoleMessageRecordInput] { #if DEBUG performanceCounters.fullRecordProjectionCount += orderedMessageIDs.count #endif @@ -529,11 +608,7 @@ package final class ConsoleMessageStore { } } - private func recordInput( - for message: ConsoleMessage, - isolation: isolated (any Actor) - ) -> ConsoleMessageRecordInput { - _ = isolation + private func recordInput(for message: ConsoleMessage) -> ConsoleMessageRecordInput { #if DEBUG performanceCounters.incrementalRecordProjectionCount += 1 #endif @@ -541,16 +616,11 @@ package final class ConsoleMessageStore { return ConsoleMessageRecordInput(message: message, orderIndex: orderIndex) } - private func isCurrent( - _ message: ConsoleMessage, - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation + private func isCurrent(_ message: ConsoleMessage) -> Bool { return messagesByID[message.id] === message } - private func nextQueryIndexSequence(isolation: isolated (any Actor)) -> UInt64 { - _ = isolation + private func nextQueryIndexSequence() -> UInt64 { precondition( queryIndexSequence < UInt64.max, "ConsoleMessageIndex mutation sequence overflowed." @@ -559,17 +629,15 @@ package final class ConsoleMessageStore { return queryIndexSequence } - private func prepareQueryIndexReset( - isolation: isolated (any Actor) - ) -> QueryIndexReset { + private func prepareQueryIndexReset() -> QueryIndexReset { precondition( querySourceEpoch < UInt64.max, "Console query source epoch overflowed." ) querySourceEpoch += 1 queryIndexNeedsRebuild = false - let sequence = nextQueryIndexSequence(isolation: isolation) - let inputs = currentRecordInputs(isolation: isolation) + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() return QueryIndexReset( inputs: inputs, sequence: sequence, @@ -577,23 +645,18 @@ package final class ConsoleMessageStore { ) } - private func prepareEmptyQueryIndexReset( - isolation: isolated (any Actor) - ) -> QueryIndexReset { - let reset = prepareQueryIndexReset(isolation: isolation) - publishEmptyConcreteQueryResults(for: reset, isolation: isolation) + private func prepareEmptyQueryIndexReset() -> QueryIndexReset { + let reset = prepareQueryIndexReset() + publishEmptyConcreteQueryResults(for: reset) return reset } - private func publishEmptyConcreteQueryResults( - for reset: QueryIndexReset, - isolation: isolated (any Actor) - ) { + private func publishEmptyConcreteQueryResults(for reset: QueryIndexReset) { precondition( reset.inputs.isEmpty, "Only a full Console lifecycle reset can publish an immediate empty query state." ) - pruneConcreteQueryRegistrations(isolation: isolation) + pruneConcreteQueryRegistrations() let projection = ConsoleMessageIndex.QueryProjection( sourceEpoch: reset.sourceEpoch, sequence: reset.sequence, @@ -613,144 +676,56 @@ package final class ConsoleMessageStore { } } - private func syncQueryIndexIfNeeded(isolation: isolated (any Actor)) async { + private nonisolated(nonsending) func syncQueryIndexIfNeeded() async { guard queryIndexNeedsRebuild else { return } queryIndexNeedsRebuild = false - let sequence = nextQueryIndexSequence(isolation: isolation) - let inputs = currentRecordInputs(isolation: isolation) + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() let deliveries = await queryIndex.replace( with: inputs, sequence: sequence, sourceEpoch: querySourceEpoch ) - await applyConcreteDeliveries(deliveries, isolation: isolation) + await applyConcreteDeliveries(deliveries) } - private func notifyMessageInserted( + private nonisolated(nonsending) func notifyMessageInserted( _ message: ConsoleMessage, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { - await syncQueryIndexIfNeeded(isolation: isolation) - guard isCurrent(message, isolation: isolation) else { + await syncQueryIndexIfNeeded() + guard isCurrent(message) else { return } - let sequence = nextQueryIndexSequence(isolation: isolation) - let input = recordInput(for: message, isolation: isolation) + let sequence = nextQueryIndexSequence() + let input = recordInput(for: message) let deliveries = await queryIndex.upsert(input, sequence: sequence) - await applyConcreteDeliveries(deliveries, isolation: isolation) - guard isCurrent(message, isolation: isolation) else { + await applyConcreteDeliveries(deliveries) + guard isCurrent(message) else { return } - await applyResultDeltas( - for: message, - inserted: true, - modelContext: modelContext, - isolation: isolation - ) } - private func notifyMessageMutated( + private nonisolated(nonsending) func notifyMessageMutated( _ message: ConsoleMessage, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { - await syncQueryIndexIfNeeded(isolation: isolation) - guard isCurrent(message, isolation: isolation) else { + await syncQueryIndexIfNeeded() + guard isCurrent(message) else { return } - let sequence = nextQueryIndexSequence(isolation: isolation) - let input = recordInput(for: message, isolation: isolation) + let sequence = nextQueryIndexSequence() + let input = recordInput(for: message) let deliveries = await queryIndex.upsert(input, sequence: sequence) - await applyConcreteDeliveries(deliveries, isolation: isolation) - guard isCurrent(message, isolation: isolation) else { - return - } - await applyResultDeltas( - for: message, - inserted: false, - modelContext: modelContext, - isolation: isolation - ) - } - - private func applyResultDeltas( - for message: ConsoleMessage, - inserted: Bool, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) - ) async { - pruneFetchedResults(isolation: isolation) - for registration in fetchedResults { - guard let results = registration.value else { - continue - } - let plan = results.currentConsoleQueryPlan() - if plan.requiresModelQuery { - if inserted { - results.insertConsoleMessage( - message, - lookup: { id in self.messagesByID[id] } - ) - } else { - results.refreshConsoleMessageAfterMutation( - message, - lookup: { id in self.messagesByID[id] } - ) - } - continue - } - let oldSnapshot = results.consoleSnapshotForDelta - let resultTopologyRevision = results.topologyRevision - let resultIndexSequence = results.consoleIndexSequenceForDelta - let indexSequence = queryIndexSequence - let delta = await queryIndex.delta( - plan: plan, - sectionBy: results.sectionBy, - oldSnapshot: oldSnapshot, - changedSince: resultIndexSequence - ) - guard queryIndexSequence == indexSequence, - results.topologyRevision == resultTopologyRevision, - results.consoleSnapshotForDelta == oldSnapshot else { - continue - } - results.applyConsoleDelta( - delta, - lookup: { id in self.messageForResult(id, isolation: isolation) } - ) - } - } - - private func refreshAllResults( - modelContext: WebInspectorContext, - isolation: isolated (any Actor) - ) { - pruneFetchedResults(isolation: isolation) - guard fetchedResults.isEmpty == false else { + await applyConcreteDeliveries(deliveries) + guard isCurrent(message) else { return } - let messages = currentMessages(isolation: isolation) - for registration in fetchedResults { - guard let results = registration.value else { - continue - } - let plan = ConsoleMessageQueryPlan(descriptor: results.fetchDescriptor) - results.setConsoleItems( - messages, - plan: plan, - indexSequence: queryIndexSequence, - lookup: { id in self.messagesByID[id] } - ) - } } - private func allocateConcreteQueryRegistrationID( - isolation: isolated (any Actor) - ) -> WebInspectorQueryRegistrationID { - _ = isolation + private func allocateConcreteQueryRegistrationID() -> WebInspectorQueryRegistrationID { precondition( nextConcreteQueryRegistrationID < UInt64.max, "Console concrete query registration identity overflowed." @@ -760,17 +735,19 @@ package final class ConsoleMessageStore { return id } - private func applyConcreteDeliveries( - _ deliveries: [ConsoleMessageIndex.QueryDelivery], - isolation: isolated (any Actor) - ) async { - pruneConcreteQueryRegistrations(isolation: isolation) - var acknowledgements: [( - id: WebInspectorQueryRegistrationID, - generation: UInt64, - sourceEpoch: UInt64, - sequence: UInt64 - )] = [] + 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 @@ -798,14 +775,14 @@ package final class ConsoleMessageStore { query: registration.activeQuery, generation: delivery.generation, isReplacement: false, - lookup: { id in self.messageForResult(id, isolation: isolation) } + lookup: { id in self.messageForResult(id) } ) if applied { - acknowledgements.append(( - id, - delivery.generation, - delivery.projection.sourceEpoch, - delivery.projection.sequence + acknowledgements.append(IndexAcknowledgementWork.Entry( + id: id, + generation: delivery.generation, + sourceEpoch: delivery.projection.sourceEpoch, + sequence: delivery.projection.sequence )) } } else if var candidate = registration.candidate, @@ -821,14 +798,7 @@ package final class ConsoleMessageStore { } concreteQueryRegistrations[id] = registration } - for acknowledgement in acknowledgements { - await queryIndex.acknowledge( - id: acknowledgement.id, - generation: acknowledgement.generation, - sourceEpoch: acknowledgement.sourceEpoch, - sequence: acknowledgement.sequence - ) - } + return acknowledgements } private func buffer( @@ -905,38 +875,25 @@ package final class ConsoleMessageStore { ) } - private func pruneConcreteQueryRegistrations(isolation: isolated (any Actor)) { - _ = isolation + private func pruneConcreteQueryRegistrations() { concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in registration.results.value != nil } } #if DEBUG - package func concreteQueryRegistrationCountForTesting( - isolation: isolated (any Actor) = #isolation - ) async -> Int { - _ = isolation + package nonisolated(nonsending) func concreteQueryRegistrationCountForTesting() async -> Int { return await queryIndex.queryRegistrationCountForTesting() } #endif - private func messageForResult( - _ id: ConsoleMessage.ID, - isolation: isolated (any Actor) - ) -> ConsoleMessage? { - _ = isolation + private func messageForResult(_ id: ConsoleMessage.ID) -> ConsoleMessage? { #if DEBUG performanceCounters.resultIdentityLookupCount += 1 #endif return messagesByID[id] } - private func pruneFetchedResults(isolation: isolated (any Actor)) { - _ = isolation - fetchedResults.removeAll { $0.value == nil } - } - private func skipEvent(_ reason: String) { WebInspectorDataKitLog.debug("event skipped: \(reason)") } diff --git a/Sources/WebInspectorDataKit/DOMMutation.swift b/Sources/WebInspectorDataKit/DOMMutation.swift index 4ff7a990..50adef76 100644 --- a/Sources/WebInspectorDataKit/DOMMutation.swift +++ b/Sources/WebInspectorDataKit/DOMMutation.swift @@ -10,17 +10,50 @@ public enum DOMRevealPolicy: Sendable, Hashable { 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] +/// 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 - /// Node identities accepted by the backend mutation. - public var acceptedNodeIDs: [DOMNode.ID] + 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? - /// Creates a DOM mutation result. - public init(requestedNodeIDs: [DOMNode.ID], acceptedNodeIDs: [DOMNode.ID]) { + public init( + requestedNodeIDs: [DOMNode.ID], + appliedNodeIDs: [DOMNode.ID], + failures: [DOMMutationFailure], + undo: DOMUndoCapability? + ) { self.requestedNodeIDs = requestedNodeIDs - self.acceptedNodeIDs = acceptedNodeIDs + 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 index 207eecb9..799ad741 100644 --- a/Sources/WebInspectorDataKit/DOMStateStore.swift +++ b/Sources/WebInspectorDataKit/DOMStateStore.swift @@ -2,7 +2,7 @@ import WebInspectorProxyKit /// Owns the semantic DOM graph and every mutation that can change it. /// -/// `WebInspectorContext` remains the attachment and I/O coordinator. It gives +/// `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 { @@ -43,6 +43,7 @@ package final class DOMStateStore { 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 @@ -57,6 +58,7 @@ package final class DOMStateStore { selectedNode = nil isElementPickerEnabled = false documentEpoch = 0 + selectionRevision = 0 nodesByID = [:] frameDocumentProjectionIndex = FrameDocumentProjectionIndex() treeStates = [] @@ -67,45 +69,60 @@ package final class DOMStateStore { } package func node( - for id: DOMNode.ID, - isolation: isolated (any Actor) = #isolation + for id: DOMNode.ID ) -> DOMNode? { - _ = isolation return nodesByID[id] } package func requiredNode( - for id: DOMNode.ID, - isolation: isolated (any Actor) = #isolation + for id: DOMNode.ID ) throws -> DOMNode { - _ = isolation guard let node = nodesByID[id] else { - throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorModelContext.") } return node } @discardableResult package func registeredNode( - _ node: DOMNode, - isolation: isolated (any Actor) = #isolation + _ node: DOMNode ) throws -> DOMNode { - _ = isolation guard nodesByID[node.id] === node else { - throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorModelContext.") } return node } + package func styles( + containing propertyID: CSSStyleProperty.ID + ) -> CSSStyles? { + nodesByID.values.lazy.compactMap(\.elementStyles).first { + $0.contains(propertyID: propertyID) + } + } + + 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, - isolation: isolated (any Actor) = #isolation + reveal: DOMRevealPolicy ) -> Effects { - _ = isolation if let node, nodesByID[node.id] !== node { - preconditionFailure("DOMNode is not registered in this WebInspectorContext.") + preconditionFailure("DOMNode is not registered in this WebInspectorModelContext.") } + advanceSelectionRevision() pendingInspectedNodeID = nil selectedNode = node notifyDOMTreeSelectionChanged(node, reveal: reveal) @@ -113,15 +130,13 @@ package final class DOMStateStore { } package func treeController( - root requestedRoot: DOMNode?, - isolation: isolated (any Actor) = #isolation + root requestedRoot: DOMNode? ) throws -> DOMTreeController { - _ = 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.") + preconditionFailure("DOMTreeController root is not registered in this WebInspectorModelContext.") } let tree = DOMTreeState(rootNode: root, selectedNode: selectedNode) @@ -131,9 +146,7 @@ package final class DOMStateStore { } package func rootTreeController( - isolation: isolated (any Actor) = #isolation ) -> DOMTreeController { - _ = isolation let tree = DOMTreeState(rootNode: rootNode, selectedNode: selectedNode) treeStates.append(WeakDOMTreeState(tree)) pruneReleasedTreeStates() @@ -141,18 +154,14 @@ package final class DOMStateStore { } package func currentTreeSnapshot( - isolation: isolated (any Actor) = #isolation ) -> DOMTreeSnapshot { - _ = isolation return DOMTreeSnapshot.make(revision: 0, rootNode: rootNode, selectedNode: selectedNode) } package func currentTreeSnapshot( - containing nodes: [DOMNode], - isolation: isolated (any Actor) = #isolation + containing nodes: [DOMNode] ) throws -> DOMTreeSnapshot { - _ = isolation - let snapshot = currentTreeSnapshot(isolation: isolation) + 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.") } @@ -160,15 +169,13 @@ package final class DOMStateStore { } package func sortedDeletionNodes( - for nodeIDs: [DOMNode.ID], - isolation: isolated (any Actor) = #isolation + for nodeIDs: [DOMNode.ID] ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { - _ = isolation var seenNodeIDs: Set = [] let nodes = try nodeIDs - .map { try requiredNode(for: $0, isolation: isolation) } + .map { try requiredNode(for: $0) } .filter { seenNodeIDs.insert($0.id).inserted } - let snapshot = try currentTreeSnapshot(containing: nodes, isolation: isolation) + let snapshot = try currentTreeSnapshot(containing: nodes) let sortedNodes = nodes.sorted { snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count } @@ -176,15 +183,13 @@ package final class DOMStateStore { } package func sortedDeletionNodes( - for nodes: [DOMNode], - isolation: isolated (any Actor) = #isolation + for nodes: [DOMNode] ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { - _ = isolation var seenNodeIDs: Set = [] let registeredNodes = try nodes - .map { try registeredNode($0, isolation: isolation) } + .map { try registeredNode($0) } .filter { seenNodeIDs.insert($0.id).inserted } - let snapshot = try currentTreeSnapshot(containing: registeredNodes, isolation: isolation) + let snapshot = try currentTreeSnapshot(containing: registeredNodes) let sortedNodes = registeredNodes.sorted { snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count } @@ -193,10 +198,8 @@ package final class DOMStateStore { package func clearSelectionIfDeleted( _ deletedRootIDs: [DOMNode.ID], - snapshot: DOMTreeSnapshot, - isolation: isolated (any Actor) = #isolation + snapshot: DOMTreeSnapshot ) -> Effects { - _ = isolation guard let selectedNode else { return Effects() } @@ -217,41 +220,31 @@ package final class DOMStateStore { } package func setElementPickerEnabled( - _ enabled: Bool, - isolation: isolated (any Actor) = #isolation + _ enabled: Bool ) -> Effects { - _ = isolation isElementPickerEnabled = enabled return Effects(statusChanged: true) } package func recordPageHighlight( - isolation: isolated (any Actor) = #isolation ) { - _ = isolation pageHighlightDocumentEpoch = documentEpoch } package func clearPageHighlight( - isolation: isolated (any Actor) = #isolation ) { - _ = isolation pageHighlightDocumentEpoch = nil } package func shouldSendPageHighlightClearAfterReset( - isolation: isolated (any Actor) = #isolation ) -> Bool { - _ = isolation return pageHighlightDocumentEpoch == nil } package func recordEditHistoryTarget( _ target: WebInspectorTarget, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation + options: DOMMutationPolicy ) { - _ = isolation guard options.undo == .automatic else { return } @@ -262,10 +255,8 @@ package final class DOMStateStore { package func undoRedoTarget( capturedTarget: WebInspectorTarget?, fallbackTarget: WebInspectorTarget?, - documentEpoch capturedDocumentEpoch: Int, - isolation: isolated (any Actor) = #isolation + documentEpoch capturedDocumentEpoch: Int ) throws -> WebInspectorTarget { - _ = isolation guard documentEpoch == capturedDocumentEpoch else { throw WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.") } @@ -283,25 +274,20 @@ package final class DOMStateStore { } package func capturedEditHistoryTarget( - isolation: isolated (any Actor) = #isolation ) -> WebInspectorTarget? { - _ = isolation return editHistoryTarget } @discardableResult package func advanceDocumentEpoch( - isolation: isolated (any Actor) = #isolation ) -> Int { - _ = isolation documentEpoch += 1 return documentEpoch } package func resetDocument( - isolation: isolated (any Actor) = #isolation ) -> Effects { - _ = isolation + advanceSelectionRevision() let shouldClearPageHighlight = pageHighlightDocumentEpoch != nil rootNode = nil selectedNode = nil @@ -325,30 +311,26 @@ package final class DOMStateStore { package func apply( _ event: DOM.Event, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) -> Effects { - _ = isolation var effects = Effects() switch event { case .documentUpdated: - advanceDocumentEpoch(isolation: isolation) - effects.merge(resetDocument(isolation: isolation)) + advanceDocumentEpoch() + effects.merge(resetDocument()) effects.shouldReloadDocument = true case let .setChildNodes(parent, nodes): effects.merge(applySetChildNodes( parent: parent, nodes: nodes, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext )) case let .childNodeInserted(parent, previous, node): effects.merge(applyChildNodeInserted( parent: parent, previous: previous, node: node, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext )) case let .childNodeRemoved(parent, node): effects.merge(applyChildNodeRemoved(parent: parent, node: node)) @@ -411,6 +393,7 @@ package final class DOMStateStore { effects.statusChanged = true let inspectedNodeID = DOMNode.ID(id) guard let node = nodesByID[inspectedNodeID], isNodeAttachedToCurrentTree(node) else { + advanceSelectionRevision() requestFrameDocumentIfNeeded( forNodeID: inspectedNodeID, reason: "DOM.inspect", @@ -418,13 +401,12 @@ package final class DOMStateStore { ) pendingInspectedNodeID = inspectedNodeID effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: true, - isolation: isolation + requestSubtreeIfNeeded: true )) return effects } pendingInspectedNodeID = nil - effects.merge(select(node, reveal: .selectAndScroll, isolation: isolation)) + effects.merge(select(node, reveal: .selectAndScroll)) effects.inspectedNode = node case .detachedRoot: skipEvent("DOM.setChildNodes detached root deferred; subtree not indexed") @@ -432,8 +414,7 @@ package final class DOMStateStore { effects.merge(applyShadowRootPushed( host: host, root: root, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext )) case let .shadowRootPopped(host, root): effects.merge(applyShadowRootPopped(host: host, root: root)) @@ -441,8 +422,7 @@ package final class DOMStateStore { effects.merge(applyPseudoElementAdded( parent: parent, element: element, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext )) case let .pseudoElementRemoved(parent, element): effects.merge(applyPseudoElementRemoved(parent: parent, element: element)) @@ -453,15 +433,21 @@ package final class DOMStateStore { 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: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) -> Effects? { - _ = isolation guard expectedEpoch == documentEpoch else { return nil } @@ -476,8 +462,7 @@ package final class DOMStateStore { ) notifyDOMTreeSnapshot(reason: reason) effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: true, - isolation: isolation + requestSubtreeIfNeeded: true )) return effects } @@ -487,10 +472,8 @@ package final class DOMStateStore { _ document: DOM.Node, frameTargetID: WebInspectorTarget.ID, expectedEpoch: Int, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) -> Effects? { - _ = isolation guard expectedEpoch == documentEpoch else { return nil } @@ -518,17 +501,14 @@ package final class DOMStateStore { notifyDOMTreeChildrenReplaced(parent: owner) } effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: true, - isolation: isolation + requestSubtreeIfNeeded: true )) return effects } package func detachProjectedFrameDocument( - forFrameID frameID: FrameID, - isolation: isolated (any Actor) = #isolation + forFrameID frameID: FrameID ) -> Effects { - _ = isolation var effects = Effects() let owners = nodesByID.values.filter { $0.isFrameOwner && $0.frameID == frameID } for owner in owners { @@ -549,8 +529,7 @@ package final class DOMStateStore { private func applySetChildNodes( parent: DOM.Node.ID, nodes: [DOM.Node], - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) -> Effects { var effects = Effects() guard let parentNode = nodesByID[DOMNode.ID(parent)] else { @@ -583,8 +562,7 @@ package final class DOMStateStore { parentNode.setChildren(newChildren) notifyDOMTreeChildrenReplaced(parent: parentNode) effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: false, - isolation: isolation + requestSubtreeIfNeeded: false )) return effects } @@ -593,8 +571,7 @@ package final class DOMStateStore { parent: DOM.Node.ID, previous: DOM.Node.ID?, node: DOM.Node, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) -> Effects { var effects = Effects() guard let parentNode = nodesByID[DOMNode.ID(parent)] else { @@ -628,8 +605,7 @@ package final class DOMStateStore { previousSiblingID: previous.map(DOMNode.ID.init) ) effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: false, - isolation: isolation + requestSubtreeIfNeeded: false )) return effects } @@ -663,8 +639,7 @@ package final class DOMStateStore { private func applyShadowRootPushed( host: DOM.Node.ID, root payload: DOM.Node, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) -> Effects { var effects = Effects() guard let hostNode = nodesByID[DOMNode.ID(host)] else { @@ -684,8 +659,7 @@ package final class DOMStateStore { hostNode.appendShadowRoot(rootNode) notifyDOMTreeChildrenReplaced(parent: hostNode) effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: false, - isolation: isolation + requestSubtreeIfNeeded: false )) return effects } @@ -711,8 +685,7 @@ package final class DOMStateStore { private func applyPseudoElementAdded( parent: DOM.Node.ID, element payload: DOM.Node, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) -> Effects { var effects = Effects() guard let parentNode = nodesByID[DOMNode.ID(parent)] else { @@ -734,8 +707,7 @@ package final class DOMStateStore { } notifyDOMTreeChildrenReplaced(parent: parentNode) effects.merge(resolvePendingInspectedNode( - requestSubtreeIfNeeded: false, - isolation: isolation + requestSubtreeIfNeeded: false )) return effects } @@ -811,7 +783,7 @@ package final class DOMStateStore { private func model( for payload: DOM.Node, preserving materializedPayloadIDs: Set, - modelContext: WebInspectorContext, + modelContext: WebInspectorModelContext, effects: inout Effects ) -> DOMNode { let id = DOMNode.ID(payload.id) @@ -826,12 +798,11 @@ package final class DOMStateStore { } previousAssociatedRoots = existing.associatedSubtreeRoots() existing.update(from: payload) - existing.setModelContext(modelContext) node = existing } else { previousChildren = [] previousAssociatedRoots = [] - node = DOMNode(node: payload, modelContext: modelContext) + node = DOMNode(node: payload) nodesByID[id] = node } @@ -1086,8 +1057,7 @@ package final class DOMStateStore { } private func resolvePendingInspectedNode( - requestSubtreeIfNeeded: Bool, - isolation: isolated (any Actor) + requestSubtreeIfNeeded: Bool ) -> Effects { var effects = Effects() guard let pendingInspectedNodeID else { @@ -1108,7 +1078,7 @@ package final class DOMStateStore { "DOM.inspect resolved pending nodeID=\(String(describing: pendingInspectedNodeID))" ) self.pendingInspectedNodeID = nil - effects.merge(select(inspectedNode, reveal: .selectAndScroll, isolation: isolation)) + effects.merge(select(inspectedNode, reveal: .selectAndScroll)) effects.inspectedNode = inspectedNode return effects } diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 7b5f6ad3..8e174643 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: WebInspectorFetchedResults = context.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 @@ -199,120 +54,15 @@ where Model.ID: Hashable & Sendable { } } -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 { +public final class WebInspectorFetchedResults { private struct State { - var fetchDescriptor: WebInspectorFetchDescriptor - var sectionBy: WebInspectorSectionDescriptor? - var networkQuery: NetworkQuery? - var consoleQuery: ConsoleQuery? var items: [Model] var sections: [WebInspectorFetchSection] var modelsByID: [Model.ID: Model] var snapshot: WebInspectorFetchedResultsSnapshot var revision: UInt64 - var topologyRevision: UInt64 var queryGeneration: UInt64? var querySourceEpoch: UInt64? var querySequence: UInt64 @@ -321,26 +71,10 @@ public final class WebInspectorFetchedResults private var state: State @ObservationIgnored private let updateBroker = WebInspectorFetchedResultsUpdateBroker() - @ObservationIgnored weak var modelContext: WebInspectorContext? - @ObservationIgnored private var networkQueryPlan: NetworkRequestQueryPlan? - @ObservationIgnored private var networkQueryState: NetworkRequestQueryState? - @ObservationIgnored private var networkIndexSequence: UInt64 - @ObservationIgnored private var consoleQueryPlan: ConsoleMessageQueryPlan? - @ObservationIgnored private var consoleQueryState: ConsoleMessageQueryState? - @ObservationIgnored private var consoleIndexSequence: UInt64 + @ObservationIgnored weak var modelContext: WebInspectorModelContext? @ObservationIgnored private var queryRegistrationID: WebInspectorQueryRegistrationID? @ObservationIgnored private var queryRegistrationLifetime: WebInspectorQueryRegistrationLifetime? - /// The descriptor currently used by the results. - public var fetchDescriptor: WebInspectorFetchDescriptor { - state.fetchDescriptor - } - - /// The section descriptor currently used by the results. - public var sectionBy: WebInspectorSectionDescriptor? { - state.sectionBy - } - /// The fetched models in display order. public var items: [Model] { state.items @@ -361,39 +95,18 @@ public final class WebInspectorFetchedResults state.revision } - package var topologyRevision: UInt64 { - state.topologyRevision - } - - init( - fetchDescriptor: WebInspectorFetchDescriptor, - sectionBy: WebInspectorSectionDescriptor? = nil, - items: [Model] = [], - modelContext: WebInspectorContext? = nil - ) { - let sections = Self.sections(for: items, sectionBy: sectionBy) + init(modelContext: WebInspectorModelContext) { state = State( - fetchDescriptor: fetchDescriptor, - sectionBy: sectionBy, - networkQuery: nil, - consoleQuery: nil, - items: items, - sections: sections, - modelsByID: Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }), - snapshot: WebInspectorFetchedResultsSnapshot(sections: sections), + items: [], + sections: [], + modelsByID: [:], + snapshot: WebInspectorFetchedResultsSnapshot(), revision: 0, - topologyRevision: 0, queryGeneration: nil, querySourceEpoch: nil, querySequence: 0 ) self.modelContext = modelContext - networkQueryPlan = nil - networkQueryState = nil - networkIndexSequence = 0 - consoleQueryPlan = nil - consoleQueryState = nil - consoleIndexSequence = 0 queryRegistrationID = nil queryRegistrationLifetime = nil } @@ -430,8 +143,6 @@ public final class WebInspectorFetchedResults } private func installInitialConcreteState( - networkQuery: NetworkQuery? = nil, - consoleQuery: ConsoleQuery? = nil, generation: UInt64, projection: WebInspectorIndexedQueryProjection, lookup: (Model.ID) -> Model? @@ -440,22 +151,13 @@ public final class WebInspectorFetchedResults state.queryGeneration == nil, "A concrete fetched-results initial state can only be installed once." ) - precondition( - (networkQuery == nil) != (consoleQuery == nil), - "A concrete fetched-results state must own exactly one query kind." - ) let resolved = resolve(projection.snapshot, reusing: [:], lookup: lookup) state = State( - fetchDescriptor: state.fetchDescriptor, - sectionBy: nil, - networkQuery: networkQuery, - consoleQuery: consoleQuery, items: resolved.items, sections: resolved.sections, modelsByID: resolved.modelsByID, snapshot: projection.snapshot, revision: 0, - topologyRevision: 0, queryGeneration: generation, querySourceEpoch: projection.sourceEpoch, querySequence: projection.sequence @@ -464,8 +166,6 @@ public final class WebInspectorFetchedResults @discardableResult private func applyConcreteProjection( - networkQuery: NetworkQuery? = nil, - consoleQuery: ConsoleQuery? = nil, generation: UInt64, projection: WebInspectorIndexedQueryProjection, isReplacement: Bool, @@ -475,16 +175,11 @@ public final class WebInspectorFetchedResults let currentSourceEpoch = state.querySourceEpoch else { preconditionFailure("A concrete fetched-results projection requires an installed initial state.") } - guard projection.sourceEpoch >= currentSourceEpoch else { - return false - } - guard generation >= currentGeneration else { + guard projection.sourceEpoch >= currentSourceEpoch, + generation >= currentGeneration else { return false } if generation == currentGeneration { - guard projection.sourceEpoch >= currentSourceEpoch else { - return false - } if projection.sourceEpoch == currentSourceEpoch, projection.sequence <= state.querySequence { return false @@ -518,8 +213,6 @@ public final class WebInspectorFetchedResults sections: resolved.sections, modelsByID: resolved.modelsByID, snapshot: projection.snapshot, - networkQuery: networkQuery, - consoleQuery: consoleQuery, queryGeneration: generation, querySourceEpoch: projection.sourceEpoch, querySequence: projection.sequence, @@ -585,160 +278,37 @@ public final class WebInspectorFetchedResults )) } - func setItems(_ items: [Model], updatedItemIDs: Set = []) { - let sections = Self.sections(for: items, sectionBy: state.sectionBy) - publish(items: items, sections: sections, updatedItemIDs: updatedItemIDs) - } - - func insertItem(_ item: Model) { - precondition( - state.items.contains { $0.id == item.id } == false, - "WebInspectorFetchedResults cannot insert a duplicate item ID." - ) - let items = state.items + [item] - let sections = Self.sections(for: items, sectionBy: state.sectionBy) - publish(items: items, sections: sections) - } - - func refreshAfterItemMutation(_ item: Model) { - guard state.items.contains(where: { $0.id == item.id }) else { - return - } - let sections = Self.sections(for: state.items, sectionBy: state.sectionBy) - publish(items: state.items, sections: sections, updatedItemIDs: [item.id]) - } - - func resetItems(_ items: [Model]) { - let sections = Self.sections(for: items, sectionBy: state.sectionBy) - publish(items: items, sections: sections, isReset: true) - } - - func applyFetchDescriptor(_ descriptor: WebInspectorFetchDescriptor, items: [Model]) { - let sections = Self.sections(for: items, sectionBy: state.sectionBy) - publish( - items: items, - sections: sections, - fetchDescriptor: descriptor, - isReset: true - ) - } - - private func applyIndexedDelta( - sequence: UInt64, - snapshot: WebInspectorFetchedResultsSnapshot, - transaction: WebInspectorFetchedResultsTransaction, - reconfigureItemIDs: Set, - currentIndexSequence: UInt64, - lookup: (Model.ID) -> Model? - ) -> UInt64 { - guard sequence > currentIndexSequence else { - return currentIndexSequence - } - if snapshot == state.snapshot { - publish( - items: state.items, - sections: state.sections, - snapshot: snapshot, - transaction: transaction, - updatedItemIDs: reconfigureItemIDs - ) - return sequence - } - let items = snapshot.itemIDs.map { id in - guard let model = lookup(id) else { - preconditionFailure("An indexed fetched-results snapshot referenced an unregistered \(Model.self).") - } - return model - } - let sections = snapshot.sections.map { section in - WebInspectorFetchSection( - id: section.id, - title: section.title, - items: section.itemIDs.map { id in - guard let model = lookup(id) else { - preconditionFailure("An indexed fetched-results section referenced an unregistered \(Model.self).") - } - return model - } - ) - } - publish( - items: items, - sections: sections, - snapshot: snapshot, - transaction: transaction, - updatedItemIDs: reconfigureItemIDs - ) - return sequence - } - - /// Replaces the fetch descriptor and updates descriptor-backed result contents. - /// - /// Results created by ``WebInspectorContext/networkRequests(matching:isolation:)`` - /// or ``WebInspectorContext/consoleMessages(matching:isolation:)`` have a - /// concrete query owner and must instead be updated with the matching - /// domain-specific `update(_:isolation:)` overload. - /// - /// - Precondition: This result was created by a `fetchedResults(for:)` - /// overload rather than a concrete domain query API. - public func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - isolation: isolated (any Actor) = #isolation - ) { - guard state.queryGeneration == nil else { - preconditionFailure( - "A concrete query result must be updated with update(_:isolation:), not updateFetchDescriptor(_:isolation:)." - ) - } - guard let modelContext else { - preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") - } - modelContext.updateFetchDescriptor(descriptor, for: self, isolation: isolation) - } - private func publish( items: [Model], sections: [WebInspectorFetchSection], - modelsByID: [Model.ID: Model]? = nil, - snapshot: WebInspectorFetchedResultsSnapshot? = nil, - fetchDescriptor: WebInspectorFetchDescriptor? = nil, - networkQuery: NetworkQuery? = nil, - consoleQuery: ConsoleQuery? = nil, - queryGeneration: UInt64? = nil, - querySourceEpoch: UInt64? = nil, - querySequence: UInt64? = nil, - isReset: Bool = false, - transaction suppliedTransaction: WebInspectorFetchedResultsTransaction? = nil, - updatedItemIDs: Set = [] + modelsByID: [Model.ID: Model], + snapshot: WebInspectorFetchedResultsSnapshot, + queryGeneration: UInt64, + querySourceEpoch: UInt64, + querySequence: UInt64, + isReset: Bool, + updatedItemIDs: Set ) { let oldState = state - let newSnapshot = snapshot ?? WebInspectorFetchedResultsSnapshot(sections: sections) - let reconfigureItemIDs = updatedItemIDs.intersection(newSnapshot.itemIDs) + let reconfigureItemIDs = updatedItemIDs.intersection(snapshot.itemIDs) guard isReset - || oldState.snapshot != newSnapshot + || oldState.snapshot != snapshot || reconfigureItemIDs.isEmpty == false else { return } let transaction: WebInspectorFetchedResultsTransaction - if let suppliedTransaction { - precondition( - suppliedTransaction.oldSnapshot == oldState.snapshot - && suppliedTransaction.newSnapshot == newSnapshot, - "A fetched-results transaction must describe the state publication it accompanies." - ) - transaction = suppliedTransaction - } else if isReset { + if isReset { transaction = WebInspectorFetchedResultsTransaction( oldSnapshot: oldState.snapshot, - newSnapshot: newSnapshot, + newSnapshot: snapshot, isReset: true, itemChanges: [] ) } else { transaction = WebInspectorFetchedResultsTransaction( oldSnapshot: oldState.snapshot, - newSnapshot: newSnapshot, + newSnapshot: snapshot, updatedItemIDs: reconfigureItemIDs ) } @@ -748,30 +318,15 @@ public final class WebInspectorFetchedResults "WebInspectorFetchedResults publication revision overflowed." ) let revision = oldState.revision + 1 - let topologyRevision: UInt64 - if isReset || oldState.snapshot != newSnapshot { - precondition( - oldState.topologyRevision < UInt64.max, - "WebInspectorFetchedResults topology revision overflowed." - ) - topologyRevision = oldState.topologyRevision + 1 - } else { - topologyRevision = oldState.topologyRevision - } state = State( - fetchDescriptor: fetchDescriptor ?? oldState.fetchDescriptor, - sectionBy: oldState.sectionBy, - networkQuery: networkQuery ?? oldState.networkQuery, - consoleQuery: consoleQuery ?? oldState.consoleQuery, items: items, sections: sections, - modelsByID: modelsByID ?? Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }), - snapshot: newSnapshot, + modelsByID: modelsByID, + snapshot: snapshot, revision: revision, - topologyRevision: topologyRevision, - queryGeneration: queryGeneration ?? oldState.queryGeneration, - querySourceEpoch: querySourceEpoch ?? oldState.querySourceEpoch, - querySequence: querySequence ?? oldState.querySequence + queryGeneration: queryGeneration, + querySourceEpoch: querySourceEpoch, + querySequence: querySequence ) updateBroker.yield(.transaction( revision: revision, @@ -779,65 +334,6 @@ public final class WebInspectorFetchedResults reconfigureItemIDs: reconfigureItemIDs )) } - - 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 - ) - ] - } - - 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])) - } - } - return sections.map { - WebInspectorFetchSection(id: $0.id, title: $0.title, items: $0.items) - } - } - - 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 title = value ?? "" - return (WebInspectorFetchSectionID(rawValue: title), title) - } } extension WebInspectorFetchedResults where Model == NetworkRequest { @@ -848,7 +344,6 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { lookup: (NetworkRequest.ID) -> NetworkRequest? ) { installInitialConcreteState( - networkQuery: query, generation: generation, projection: projection, lookup: lookup @@ -864,7 +359,6 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { lookup: (NetworkRequest.ID) -> NetworkRequest? ) -> Bool { applyConcreteProjection( - networkQuery: query, generation: generation, projection: projection, isReplacement: isReplacement, @@ -872,133 +366,17 @@ extension WebInspectorFetchedResults where Model == NetworkRequest { ) } - /// Replaces this result's concrete Network query atomically. - /// - /// - Precondition: This result was created by - /// ``WebInspectorContext/networkRequests(matching:isolation:)``. - public func update( - _ query: NetworkQuery, - isolation: isolated (any Actor) = #isolation - ) async throws { + /// 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:isolation:)." + "A Network query can only update results created by networkRequests(matching:)." ) } guard let modelContext else { - preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") - } - try await modelContext.updateNetworkQuery(query, for: self, isolation: isolation) - } - - var networkSnapshotForDelta: WebInspectorFetchedResultsSnapshot { - state.snapshot - } - - var networkIndexSequenceForDelta: UInt64 { - networkIndexSequence - } - - func currentNetworkQueryPlan(context: WebInspectorContext) -> NetworkRequestQueryPlan { - if let networkQueryPlan { - return networkQueryPlan - } - let plan = NetworkRequestQueryPlan(descriptor: state.fetchDescriptor, context: context) - networkQueryPlan = plan - return plan - } - - func setNetworkItems( - _ requests: [NetworkRequest], - plan: NetworkRequestQueryPlan, - indexSequence: UInt64, - 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) - } - networkIndexSequence = indexSequence - } - - func applyNetworkFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - plan: NetworkRequestQueryPlan, - requests: [NetworkRequest], - indexSequence: UInt64, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - networkQueryPlan = plan - let visibleRequests: [NetworkRequest] - if plan.requiresQuery { - let state = NetworkRequestQueryState(plan: plan, requests: requests) - networkQueryState = state - visibleRequests = state.visibleRequests(lookup: lookup) - } else { - networkQueryState = nil - visibleRequests = requests + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorModelContext.") } - let sections = Self.sections(for: visibleRequests, sectionBy: state.sectionBy) - publish( - items: visibleRequests, - sections: sections, - fetchDescriptor: descriptor, - isReset: true - ) - networkIndexSequence = indexSequence - } - - func resetNetworkItems(indexSequence: UInt64) { - if let state = networkQueryState { - networkQueryState = NetworkRequestQueryState(plan: state.plan, requests: []) - } - resetItems([]) - networkIndexSequence = indexSequence - } - - func insertNetworkRequest( - _ request: NetworkRequest, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - guard var state = networkQueryState else { - insertItem(request) - return - } - state.upsert(request: request) - networkQueryState = state - setItems(state.visibleRequests(lookup: lookup)) - } - - func refreshNetworkRequestAfterMutation( - _ request: NetworkRequest, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - guard var state = networkQueryState else { - refreshAfterItemMutation(request) - return - } - state.upsert(request: request) - networkQueryState = state - setItems(state.visibleRequests(lookup: lookup), updatedItemIDs: [request.id]) - } - - func applyNetworkDelta( - _ delta: NetworkResultSetDelta, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - networkIndexSequence = applyIndexedDelta( - sequence: delta.sequence, - snapshot: delta.snapshot, - transaction: delta.transaction, - reconfigureItemIDs: delta.reconfigureItemIDs, - currentIndexSequence: networkIndexSequence, - lookup: lookup - ) + try await modelContext.updateNetworkQuery(query, for: self) } } @@ -1010,7 +388,6 @@ extension WebInspectorFetchedResults where Model == ConsoleMessage { lookup: (ConsoleMessage.ID) -> ConsoleMessage? ) { installInitialConcreteState( - consoleQuery: query, generation: generation, projection: projection, lookup: lookup @@ -1026,7 +403,6 @@ extension WebInspectorFetchedResults where Model == ConsoleMessage { lookup: (ConsoleMessage.ID) -> ConsoleMessage? ) -> Bool { applyConcreteProjection( - consoleQuery: query, generation: generation, projection: projection, isReplacement: isReplacement, @@ -1034,124 +410,16 @@ extension WebInspectorFetchedResults where Model == ConsoleMessage { ) } - /// Replaces this result's concrete Console query atomically. - /// - /// - Precondition: This result was created by - /// ``WebInspectorContext/consoleMessages(matching:isolation:)``. - public func update( - _ query: ConsoleQuery, - isolation: isolated (any Actor) = #isolation - ) async throws { + /// 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:isolation:)." + "A Console query can only update results created by consoleMessages(matching:)." ) } guard let modelContext else { - preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") - } - try await modelContext.updateConsoleQuery(query, for: self, isolation: isolation) - } - - var consoleSnapshotForDelta: WebInspectorFetchedResultsSnapshot { - state.snapshot - } - - var consoleIndexSequenceForDelta: UInt64 { - consoleIndexSequence - } - - func currentConsoleQueryPlan() -> ConsoleMessageQueryPlan { - if let consoleQueryPlan { - return consoleQueryPlan - } - let plan = ConsoleMessageQueryPlan(descriptor: state.fetchDescriptor) - consoleQueryPlan = plan - return plan - } - - func setConsoleItems( - _ messages: [ConsoleMessage], - plan: ConsoleMessageQueryPlan, - indexSequence: UInt64, - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) { - consoleQueryPlan = plan - if plan.requiresQuery { - let queryState = ConsoleMessageQueryState(plan: plan, messages: messages) - consoleQueryState = queryState - setItems(queryState.visibleMessages(lookup: lookup)) - } else { - consoleQueryState = nil - setItems(messages) - } - consoleIndexSequence = indexSequence - } - - func applyConsoleFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - plan: ConsoleMessageQueryPlan, - messages: [ConsoleMessage], - indexSequence: UInt64, - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) { - consoleQueryPlan = plan - let visibleMessages: [ConsoleMessage] - if plan.requiresQuery { - let queryState = ConsoleMessageQueryState(plan: plan, messages: messages) - consoleQueryState = queryState - visibleMessages = queryState.visibleMessages(lookup: lookup) - } else { - consoleQueryState = nil - visibleMessages = messages - } - let sections = Self.sections(for: visibleMessages, sectionBy: state.sectionBy) - publish( - items: visibleMessages, - sections: sections, - fetchDescriptor: descriptor, - isReset: true - ) - consoleIndexSequence = indexSequence - } - - func insertConsoleMessage( - _ message: ConsoleMessage, - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) { - guard var queryState = consoleQueryState else { - insertItem(message) - return - } - queryState.upsert(message: message) - consoleQueryState = queryState - setItems(queryState.visibleMessages(lookup: lookup)) - } - - func refreshConsoleMessageAfterMutation( - _ message: ConsoleMessage, - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) { - guard var queryState = consoleQueryState else { - refreshAfterItemMutation(message) - return + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorModelContext.") } - queryState.upsert(message: message) - consoleQueryState = queryState - setItems(queryState.visibleMessages(lookup: lookup), updatedItemIDs: [message.id]) - } - - func applyConsoleDelta( - _ delta: ConsoleResultSetDelta, - lookup: (ConsoleMessage.ID) -> ConsoleMessage? - ) { - consoleIndexSequence = applyIndexedDelta( - sequence: delta.sequence, - snapshot: delta.snapshot, - transaction: delta.transaction, - reconfigureItemIDs: delta.reconfigureItemIDs, - currentIndexSequence: consoleIndexSequence, - lookup: lookup - ) + try await modelContext.updateConsoleQuery(query, for: self) } } diff --git a/Sources/WebInspectorDataKit/NetworkRequest.swift b/Sources/WebInspectorDataKit/NetworkRequest.swift index 5dfff111..6b242ee5 100644 --- a/Sources/WebInspectorDataKit/NetworkRequest.swift +++ b/Sources/WebInspectorDataKit/NetworkRequest.swift @@ -644,7 +644,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 @@ -771,7 +771,7 @@ public final class NetworkRequest: WebInspectorFetchableModel { /// Response body state. public private(set) var responseBody: NetworkBody - @ObservationIgnored weak var modelContext: WebInspectorContext? + @ObservationIgnored weak var modelContext: WebInspectorModelContext? @ObservationIgnored private var currentRequest: Network.Request var proxyID: Network.Request.ID { @@ -797,7 +797,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 +883,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?, diff --git a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index c5e81ea6..3e6b507b 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -1,12 +1,5 @@ import Foundation -package struct NetworkResultSetDelta: Sendable { - package var sequence: UInt64 - package var snapshot: WebInspectorFetchedResultsSnapshot - package var transaction: WebInspectorFetchedResultsTransaction - package var reconfigureItemIDs: Set -} - package actor NetworkRequestIndex { package typealias QueryProjection = WebInspectorIndexedQueryProjection package typealias QueryDelivery = WebInspectorIndexedQueryDelivery @@ -572,97 +565,4 @@ package actor NetworkRequestIndex { }) } - // Legacy generic-descriptor projection retained only until the final query - // migration commit removes the generic surface. - package func delta( - plan: NetworkRequestQueryPlan, - sectionBy: WebInspectorSectionDescriptor?, - oldSnapshot: WebInspectorFetchedResultsSnapshot, - changedSince sequence: UInt64 - ) -> NetworkResultSetDelta { - precondition(plan.requiresModelPredicate == false) - let indexSequence = lastAppliedSequence - let newSnapshot = legacySnapshot(plan: plan, sectionBy: sectionBy) - let oldItemIDs = Set(oldSnapshot.itemIDs) - let updatedItemIDs = Set(newSnapshot.itemIDs.filter { id in - oldItemIDs.contains(id) && lastUpdatedSequenceByID[id, default: 0] > sequence - }) - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - updatedItemIDs: updatedItemIDs - ) - return NetworkResultSetDelta( - sequence: indexSequence, - snapshot: newSnapshot, - transaction: transaction, - reconfigureItemIDs: updatedItemIDs - ) - } - - private func legacySnapshot( - plan: NetworkRequestQueryPlan, - sectionBy: WebInspectorSectionDescriptor? - ) -> WebInspectorFetchedResultsSnapshot { - let matchingRecords = legacyVisibleRecords(plan: plan) - guard matchingRecords.isEmpty == false else { - return WebInspectorFetchedResultsSnapshot() - } - guard let sectionBy else { - return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) - } - var sections: [( - id: WebInspectorFetchSectionID, - title: String?, - itemIDs: [NetworkRequest.ID] - )] = [] - for record in matchingRecords { - let identity = legacySectionIdentity(for: record, sectionBy: sectionBy) - if let index = sections.firstIndex(where: { $0.id == identity.id }) { - sections[index].itemIDs.append(record.id) - } else { - sections.append((identity.id, identity.title, [record.id])) - } - } - return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in - WebInspectorFetchedResultsSnapshot.Section( - id: section.id, - title: section.title, - itemIDs: section.itemIDs - ) - }) - } - - private func legacyVisibleRecords(plan: NetworkRequestQueryPlan) -> [NetworkRequestRecord] { - var records = orderedIDs.compactMap { recordsByID[$0] }.filter { - plan.matches(record: $0) == true - } - if plan.sortComparators.isEmpty == false { - records.sort { plan.ordersBefore($0, $1) } - } - let lowerBound = min(plan.fetchOffset, records.count) - let upperBound = plan.fetchLimit.map { min(lowerBound + $0, records.count) } ?? 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) - } } diff --git a/Sources/WebInspectorDataKit/NetworkRequestQuery.swift b/Sources/WebInspectorDataKit/NetworkRequestQuery.swift index 19d424d5..f14d2a5e 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestQuery.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestQuery.swift @@ -83,566 +83,3 @@ package struct NetworkRequestRecord: Hashable, Sendable { self.init(input: NetworkRequestRecordInput(request: request, orderIndex: orderIndex)) } } - -package struct NetworkRequestQueryPlan: Sendable { - package typealias RecordPredicate = @Sendable (NetworkRequestRecord) -> 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 index 94e2b901..414813e5 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestStore.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestStore.swift @@ -3,10 +3,68 @@ import WebInspectorProxyKit /// Owns Network request identity, order, query projection, and publication. /// -/// `WebInspectorContext` remains the attachment and transport coordinator. The -/// store is confined by the actor supplied by each caller and never retains an -/// actor token or starts transport work. +/// `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 @@ -31,6 +89,11 @@ package final class NetworkRequestStore { fileprivate var sourceEpoch: UInt64 } + private struct ModelChange { + let request: NetworkRequest + let inserted: Bool + } + #if DEBUG package struct PerformanceCounters: Equatable { package var fullModelProjectionCount = 0 @@ -49,7 +112,6 @@ package final class NetworkRequestStore { private let queryIndex: NetworkRequestIndex private var queryIndexSequence: UInt64 private var queryIndexNeedsRebuild: Bool - private var fetchedResults: [WeakWebInspectorFetchedResults] private var querySourceEpoch: UInt64 private var nextConcreteQueryRegistrationID: UInt64 private var initializingConcreteQueries: [ @@ -71,7 +133,6 @@ package final class NetworkRequestStore { queryIndex = NetworkRequestIndex() queryIndexSequence = 0 queryIndexNeedsRebuild = false - fetchedResults = [] querySourceEpoch = 0 nextConcreteQueryRegistrationID = 0 initializingConcreteQueries = [:] @@ -86,69 +147,38 @@ package final class NetworkRequestStore { performanceCounters } - package func resetPerformanceCountersForTesting( - isolation: isolated (any Actor) = #isolation - ) { - _ = isolation + package func resetPerformanceCountersForTesting() { performanceCounters = PerformanceCounters() } #endif - package func request( - for id: NetworkRequest.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - _ = isolation - return requestsByID[id] + package func request(for id: NetworkRequest.ID) -> NetworkRequest? { + requestsByID[id] } - package func request( - forProxyID id: Network.Request.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - request(for: NetworkRequest.ID(id), isolation: isolation) + package func request(forProxyID id: Network.Request.ID) -> NetworkRequest? { + request(for: NetworkRequest.ID(id)) } package func finishResponseBodyFetch( _ result: Result, for request: NetworkRequest, - expectedBody: NetworkBody, - isolation: isolated (any Actor) = #isolation + expectedBody: NetworkBody ) { - _ = isolation guard requestsByID[request.id] === request else { return } request.finishResponseBodyFetch(result: result, expectedBody: expectedBody) } - package func register( - _ results: WebInspectorFetchedResults, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation - ) { - let plan = NetworkRequestQueryPlan(descriptor: results.fetchDescriptor, context: modelContext) - results.setNetworkItems( - currentRequests(isolation: isolation), - plan: plan, - indexSequence: queryIndexSequence, - lookup: { id in self.requestsByID[id] } - ) - fetchedResults.append(WeakWebInspectorFetchedResults(results)) - } - - package func results( + package nonisolated(nonsending) func results( matching query: NetworkQuery, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) async throws -> WebInspectorFetchedResults { - await syncQueryIndexIfNeeded(isolation: isolation) - let id = allocateConcreteQueryRegistrationID(isolation: isolation) + await syncQueryIndexIfNeeded() + let id = allocateConcreteQueryRegistrationID() let lifetime = WebInspectorQueryRegistrationLifetime() - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: modelContext - ) + let results = WebInspectorFetchedResults(modelContext: modelContext) results.installQueryRegistration(id: id, lifetime: lifetime) let generation = results.nextConcreteQueryGeneration() initializingConcreteQueries[id] = PendingConcreteQuery( @@ -187,7 +217,7 @@ package final class NetworkRequestStore { query, generation: generation, projection: installedProjection, - lookup: { id in self.requestForResult(id, isolation: isolation) } + lookup: { id in self.requestForResult(id) } ) concreteQueryRegistrations[id] = ConcreteQueryRegistration( results: WeakWebInspectorFetchedResults(results), @@ -205,15 +235,14 @@ package final class NetworkRequestStore { return results } - package func update( + package nonisolated(nonsending) func update( _ query: NetworkQuery, - for results: WebInspectorFetchedResults, - isolation: isolated (any Actor) = #isolation + for results: WebInspectorFetchedResults ) async throws { guard let id = results.concreteQueryRegistrationID else { preconditionFailure("Network fetched results are not registered in this store.") } - await syncQueryIndexIfNeeded(isolation: isolation) + await syncQueryIndexIfNeeded() guard var registration = concreteQueryRegistrations[id], registration.results.value === results else { preconditionFailure("Network fetched results are not registered in this store.") @@ -284,7 +313,7 @@ package final class NetworkRequestStore { query: query, generation: generation, isReplacement: true, - lookup: { id in self.requestForResult(id, isolation: isolation) } + lookup: { id in self.requestForResult(id) } ) if generation > afterCommit.activeGeneration { afterCommit.activeGeneration = generation @@ -301,48 +330,27 @@ package final class NetworkRequestStore { } } - package func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - for results: WebInspectorFetchedResults, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation - ) { - let plan = NetworkRequestQueryPlan(descriptor: descriptor, context: modelContext) - results.applyNetworkFetchDescriptor( - descriptor, - plan: plan, - requests: currentRequests(isolation: isolation), - indexSequence: queryIndexSequence, - lookup: { id in self.requestsByID[id] } - ) - } - /// Clears requests while remembering their protocol identities so late /// terminal events from the cleared loads remain ignorable. - package func clear(isolation: isolated (any Actor) = #isolation) async { + package nonisolated(nonsending) func clear() async { clearedRequestIDs.formUnion(requestsByID.keys) - let reset = prepareRemoveAllRequests(isolation: isolation) - await finishQueryIndexReset(reset, isolation: isolation) + let reset = prepareRemoveAllRequests() + await finishQueryIndexReset(reset) } /// Begins a new attachment epoch and immediately removes prior identities. - package func prepareResetForNewAttachment( - isolation: isolated (any Actor) = #isolation - ) -> QueryIndexReset { + package func prepareResetForNewAttachment() -> QueryIndexReset { clearedRequestIDs = [] - return prepareRemoveAllRequests(isolation: isolation) + return prepareRemoveAllRequests() } - package func finishQueryIndexReset( - _ reset: QueryIndexReset, - isolation: isolated (any Actor) = #isolation - ) async { + package nonisolated(nonsending) func finishQueryIndexReset(_ reset: QueryIndexReset) async { let deliveries = await queryIndex.replace( with: [], sequence: reset.sequence, sourceEpoch: reset.sourceEpoch ) - await applyConcreteDeliveries(deliveries, isolation: isolation) + await applyConcreteDeliveries(deliveries) } @discardableResult @@ -360,10 +368,8 @@ package final class NetworkRequestStore { responseBody: String? = nil, timestamp: Double, encodedBodyLength: Int = 0, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) -> NetworkRequest.ID { - _ = isolation let requestID = Network.Request.ID(rawRequestID) let resourceType = resourceTypeRawValue.map(Network.ResourceType.init(rawValue:)) let payload = Network.Request( @@ -392,7 +398,7 @@ package final class NetworkRequestStore { modelContext: modelContext ) requestsByID[id] = request - appendRequestID(id, isolation: isolation) + appendRequestID(id) inserted = true } request.applyResponse( @@ -425,22 +431,8 @@ package final class NetworkRequestStore { request.responseBody.load(Network.Body(data: responseBody, base64Encoded: false)) } queryIndexNeedsRebuild = true - pruneFetchedResults(isolation: isolation) if inserted { collectionState.didInsertRequest() - for registration in fetchedResults { - registration.value?.insertNetworkRequest( - request, - lookup: { id in self.requestsByID[id] } - ) - } - } else { - for registration in fetchedResults { - registration.value?.refreshNetworkRequestAfterMutation( - request, - lookup: { id in self.requestsByID[id] } - ) - } } return request.id } @@ -450,10 +442,8 @@ package final class NetworkRequestStore { body: String, base64Encoded: Bool = false, size: Int? = nil, - isTruncated: Bool = false, - isolation: isolated (any Actor) = #isolation + isTruncated: Bool = false ) { - _ = isolation guard let request = requestsByID[requestID] else { preconditionFailure("Cannot seed a response body for an unregistered NetworkRequest.") } @@ -465,10 +455,304 @@ package final class NetworkRequestStore { )) } - package func apply( + 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: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + modelContext: WebInspectorModelContext ) async { switch event { case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): @@ -478,8 +762,7 @@ package final class NetworkRequestStore { resourceType: resourceType, redirectResponse: redirectResponse, timestamp: timestamp, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) case let .responseReceived(id, response, resourceType, timestamp): await applyResponseReceived( @@ -487,11 +770,10 @@ package final class NetworkRequestStore { response: response, resourceType: resourceType, timestamp: timestamp, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) case let .dataReceived(id, dataLength, encodedDataLength, timestamp): - guard let request = request(for: id, method: "dataReceived", isolation: isolation) else { + guard let request = request(for: id, method: "dataReceived") else { return } request.applyDataReceived( @@ -499,43 +781,41 @@ package final class NetworkRequestStore { encodedDataLength: encodedDataLength, timestamp: timestamp ) - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) case let .loadingFinished(id, timestamp, sourceMapURL, metrics): - guard let request = request(for: id, method: "loadingFinished", isolation: isolation) else { + guard let request = request(for: id, method: "loadingFinished") else { return } request.finish(timestamp: timestamp, sourceMapURL: sourceMapURL, metrics: metrics) - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) case let .loadingFailed(id, errorText, canceled, timestamp): - guard let request = request(for: id, method: "loadingFailed", isolation: isolation) else { + guard let request = request(for: id, method: "loadingFailed") else { return } request.fail(errorText: errorText, canceled: canceled, timestamp: timestamp) - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) case let .webSocket(event): - await apply(event, modelContext: modelContext, isolation: isolation) + await apply(event, modelContext: modelContext) case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): await applyRequestServedFromMemoryCache( id: id, response: response, resourceType: resourceType, timestamp: timestamp, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) case .unknown: break } } - private func applyRequestWillBeSent( + private nonisolated(nonsending) func applyRequestWillBeSent( id proxyID: Network.Request.ID, request payload: Network.Request, resourceType: Network.ResourceType?, redirectResponse: Network.Response?, timestamp: Double, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { let id = NetworkRequest.ID(proxyID) guard clearedRequestIDs.contains(id) == false || redirectResponse == nil else { @@ -567,23 +847,22 @@ package final class NetworkRequestStore { modelContext: modelContext ) requestsByID[id] = request - appendRequestID(id, isolation: isolation) + appendRequestID(id) inserted = true } if inserted { - await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + await notifyRequestInserted(request, modelContext: modelContext) } else if topologyMayHaveChanged { - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) } } - private func applyRequestServedFromMemoryCache( + private nonisolated(nonsending) func applyRequestServedFromMemoryCache( id proxyID: Network.Request.ID, response: Network.Response, resourceType: Network.ResourceType?, timestamp: Double, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { let id = NetworkRequest.ID(proxyID) guard clearedRequestIDs.contains(id) == false else { @@ -610,22 +889,21 @@ package final class NetworkRequestStore { modelContext: modelContext ) requestsByID[id] = request - appendRequestID(id, isolation: isolation) + appendRequestID(id) request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) - await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + await notifyRequestInserted(request, modelContext: modelContext) return } request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) } - private func applyResponseReceived( + private nonisolated(nonsending) func applyResponseReceived( id proxyID: Network.Request.ID, response: Network.Response, resourceType: Network.ResourceType?, timestamp: Double, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { let id = NetworkRequest.ID(proxyID) guard clearedRequestIDs.contains(id) == false else { @@ -657,100 +935,91 @@ package final class NetworkRequestStore { modelContext: modelContext ) requestsByID[id] = request - appendRequestID(id, isolation: isolation) + appendRequestID(id) inserted = true } request.applyResponse(response, resourceType: resourceType, timestamp: timestamp) if inserted { - await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + await notifyRequestInserted(request, modelContext: modelContext) } else { - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) } } - private func apply( + private nonisolated(nonsending) func apply( _ event: Network.WebSocketEvent, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { switch event { case let .created(id, url): await applyWebSocketCreated( id: id, url: url, - modelContext: modelContext, - isolation: isolation + modelContext: modelContext ) case let .handshakeRequest(id, payload, timestamp): guard let networkRequest = request( for: id, - method: "webSocketWillSendHandshakeRequest", - isolation: isolation + method: "webSocketWillSendHandshakeRequest" ) else { return } networkRequest.applyWebSocketHandshakeRequest(payload, timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case let .handshakeResponse(id, response, timestamp): guard let networkRequest = request( for: id, - method: "webSocketHandshakeResponseReceived", - isolation: isolation + method: "webSocketHandshakeResponseReceived" ) else { return } networkRequest.applyWebSocketHandshakeResponse(response, timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case let .frameSent(id, frame, timestamp): guard let networkRequest = request( for: id, - method: "webSocketFrameSent", - isolation: isolation + method: "webSocketFrameSent" ) else { return } networkRequest.appendWebSocketFrame(frame, direction: .sent, timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case let .frameReceived(id, frame, timestamp): guard let networkRequest = request( for: id, - method: "webSocketFrameReceived", - isolation: isolation + method: "webSocketFrameReceived" ) else { return } networkRequest.appendWebSocketFrame(frame, direction: .received, timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case let .error(id, message, timestamp): guard let networkRequest = request( for: id, - method: "webSocketFrameError", - isolation: isolation + method: "webSocketFrameError" ) else { return } networkRequest.appendWebSocketError(message, timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case let .closed(id, timestamp): guard let networkRequest = request( for: id, - method: "webSocketClosed", - isolation: isolation + method: "webSocketClosed" ) else { return } networkRequest.closeWebSocket(timestamp: timestamp) - await notifyRequestMutated(networkRequest, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(networkRequest, modelContext: modelContext) case .other: break } } - private func applyWebSocketCreated( + private nonisolated(nonsending) func applyWebSocketCreated( id proxyID: Network.Request.ID, url: String, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { let id = NetworkRequest.ID(proxyID) clearedRequestIDs.remove(id) @@ -767,23 +1036,21 @@ package final class NetworkRequestStore { modelContext: modelContext ) requestsByID[id] = request - appendRequestID(id, isolation: isolation) + appendRequestID(id) inserted = true } request.applyWebSocketCreated(url: url) if inserted { - await notifyRequestInserted(request, modelContext: modelContext, isolation: isolation) + await notifyRequestInserted(request, modelContext: modelContext) } else { - await notifyRequestMutated(request, modelContext: modelContext, isolation: isolation) + await notifyRequestMutated(request, modelContext: modelContext) } } private func request( for proxyID: Network.Request.ID, - method: String, - isolation: isolated (any Actor) + method: String ) -> NetworkRequest? { - _ = isolation let id = NetworkRequest.ID(proxyID) guard let request = requestsByID[id] else { if clearedRequestIDs.contains(id) == false { @@ -794,30 +1061,24 @@ package final class NetworkRequestStore { return request } - private func prepareRemoveAllRequests( - isolation: isolated (any Actor) - ) -> QueryIndexReset { + private func prepareRemoveAllRequests() -> QueryIndexReset { requestsByID = [:] orderedRequestIDs = [] orderIndicesByID = [:] queryIndexNeedsRebuild = false - advanceQuerySourceEpoch(isolation: isolation) - let sequence = nextQueryIndexSequence(isolation: isolation) + advanceQuerySourceEpoch() + let sequence = nextQueryIndexSequence() collectionState.replaceCount(0) - resetFetchedResults(isolation: isolation) let reset = QueryIndexReset( sequence: sequence, sourceEpoch: querySourceEpoch ) - publishEmptyConcreteQueryResults(for: reset, isolation: isolation) + publishEmptyConcreteQueryResults(for: reset) return reset } - private func publishEmptyConcreteQueryResults( - for reset: QueryIndexReset, - isolation: isolated (any Actor) - ) { - pruneConcreteQueryRegistrations(isolation: isolation) + private func publishEmptyConcreteQueryResults(for reset: QueryIndexReset) { + pruneConcreteQueryRegistrations() let projection = NetworkRequestIndex.QueryProjection( sourceEpoch: reset.sourceEpoch, sequence: reset.sequence, @@ -837,8 +1098,7 @@ package final class NetworkRequestStore { } } - private func advanceQuerySourceEpoch(isolation: isolated (any Actor)) { - _ = isolation + private func advanceQuerySourceEpoch() { precondition( querySourceEpoch < UInt64.max, "Network query source epoch overflowed." @@ -846,27 +1106,12 @@ package final class NetworkRequestStore { querySourceEpoch += 1 } - private func currentRequests(isolation: isolated (any Actor)) -> [NetworkRequest] { - _ = isolation -#if DEBUG - performanceCounters.fullModelProjectionCount += orderedRequestIDs.count -#endif - return orderedRequestIDs.compactMap { requestsByID[$0] } - } - - private func appendRequestID( - _ id: NetworkRequest.ID, - isolation: isolated (any Actor) - ) { - _ = isolation + private func appendRequestID(_ id: NetworkRequest.ID) { orderIndicesByID[id] = orderedRequestIDs.count orderedRequestIDs.append(id) } - private func currentRecordInputs( - isolation: isolated (any Actor) - ) -> [NetworkRequestRecordInput] { - _ = isolation + private func currentRecordInputs() -> [NetworkRequestRecordInput] { #if DEBUG performanceCounters.fullRecordProjectionCount += orderedRequestIDs.count #endif @@ -875,11 +1120,7 @@ package final class NetworkRequestStore { } } - private func recordInput( - for request: NetworkRequest, - isolation: isolated (any Actor) - ) -> NetworkRequestRecordInput { - _ = isolation + private func recordInput(for request: NetworkRequest) -> NetworkRequestRecordInput { #if DEBUG performanceCounters.incrementalRecordProjectionCount += 1 #endif @@ -887,16 +1128,11 @@ package final class NetworkRequestStore { return NetworkRequestRecordInput(request: request, orderIndex: orderIndex) } - private func isCurrent( - _ request: NetworkRequest, - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation + private func isCurrent(_ request: NetworkRequest) -> Bool { return requestsByID[request.id] === request } - private func nextQueryIndexSequence(isolation: isolated (any Actor)) -> UInt64 { - _ = isolation + 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( @@ -907,122 +1143,57 @@ package final class NetworkRequestStore { return queryIndexSequence } - private func syncQueryIndexIfNeeded(isolation: isolated (any Actor)) async { + private nonisolated(nonsending) func syncQueryIndexIfNeeded() async { guard queryIndexNeedsRebuild else { return } queryIndexNeedsRebuild = false - let sequence = nextQueryIndexSequence(isolation: isolation) - let inputs = currentRecordInputs(isolation: isolation) + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() let deliveries = await queryIndex.replace( with: inputs, sequence: sequence, sourceEpoch: querySourceEpoch ) - await applyConcreteDeliveries(deliveries, isolation: isolation) + await applyConcreteDeliveries(deliveries) } - private func notifyRequestInserted( + private nonisolated(nonsending) func notifyRequestInserted( _ request: NetworkRequest, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { collectionState.didInsertRequest() - await syncQueryIndexIfNeeded(isolation: isolation) - guard isCurrent(request, isolation: isolation) else { + await syncQueryIndexIfNeeded() + guard isCurrent(request) else { return } - let sequence = nextQueryIndexSequence(isolation: isolation) - let input = recordInput(for: request, isolation: isolation) + let sequence = nextQueryIndexSequence() + let input = recordInput(for: request) let deliveries = await queryIndex.upsert(input, sequence: sequence) - await applyConcreteDeliveries(deliveries, isolation: isolation) - guard isCurrent(request, isolation: isolation) else { + await applyConcreteDeliveries(deliveries) + guard isCurrent(request) else { return } - await applyResultDeltas( - for: request, - inserted: true, - modelContext: modelContext, - isolation: isolation - ) } - private func notifyRequestMutated( + private nonisolated(nonsending) func notifyRequestMutated( _ request: NetworkRequest, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) + modelContext: WebInspectorModelContext ) async { - await syncQueryIndexIfNeeded(isolation: isolation) - guard isCurrent(request, isolation: isolation) else { + await syncQueryIndexIfNeeded() + guard isCurrent(request) else { return } - let sequence = nextQueryIndexSequence(isolation: isolation) - let input = recordInput(for: request, isolation: isolation) + let sequence = nextQueryIndexSequence() + let input = recordInput(for: request) let deliveries = await queryIndex.upsert(input, sequence: sequence) - await applyConcreteDeliveries(deliveries, isolation: isolation) - guard isCurrent(request, isolation: isolation) else { + await applyConcreteDeliveries(deliveries) + guard isCurrent(request) else { return } - await applyResultDeltas( - for: request, - inserted: false, - modelContext: modelContext, - isolation: isolation - ) - } - - private func applyResultDeltas( - for request: NetworkRequest, - inserted: Bool, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) - ) async { - pruneFetchedResults(isolation: isolation) - for registration in fetchedResults { - guard let results = registration.value else { - continue - } - let plan = results.currentNetworkQueryPlan(context: modelContext) - 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 resultIndexSequence = results.networkIndexSequenceForDelta - let indexSequence = queryIndexSequence - let delta = await queryIndex.delta( - plan: plan, - sectionBy: results.sectionBy, - oldSnapshot: oldSnapshot, - changedSince: resultIndexSequence - ) - guard queryIndexSequence == indexSequence, - results.topologyRevision == resultTopologyRevision, - results.networkSnapshotForDelta == oldSnapshot else { - continue - } - results.applyNetworkDelta( - delta, - lookup: { id in self.requestForResult(id, isolation: isolation) } - ) - } } - private func allocateConcreteQueryRegistrationID( - isolation: isolated (any Actor) - ) -> WebInspectorQueryRegistrationID { - _ = isolation + private func allocateConcreteQueryRegistrationID() -> WebInspectorQueryRegistrationID { precondition( nextConcreteQueryRegistrationID < UInt64.max, "Network concrete query registration identity overflowed." @@ -1032,17 +1203,19 @@ package final class NetworkRequestStore { return id } - private func applyConcreteDeliveries( - _ deliveries: [NetworkRequestIndex.QueryDelivery], - isolation: isolated (any Actor) - ) async { - pruneConcreteQueryRegistrations(isolation: isolation) - var acknowledgements: [( - id: WebInspectorQueryRegistrationID, - generation: UInt64, - sourceEpoch: UInt64, - sequence: UInt64 - )] = [] + 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 @@ -1070,14 +1243,14 @@ package final class NetworkRequestStore { query: registration.activeQuery, generation: delivery.generation, isReplacement: false, - lookup: { id in self.requestForResult(id, isolation: isolation) } + lookup: { id in self.requestForResult(id) } ) if applied { - acknowledgements.append(( - id, - delivery.generation, - delivery.projection.sourceEpoch, - delivery.projection.sequence + acknowledgements.append(IndexAcknowledgementWork.Entry( + id: id, + generation: delivery.generation, + sourceEpoch: delivery.projection.sourceEpoch, + sequence: delivery.projection.sequence )) } } else if var candidate = registration.candidate, @@ -1093,14 +1266,7 @@ package final class NetworkRequestStore { } concreteQueryRegistrations[id] = registration } - for acknowledgement in acknowledgements { - await queryIndex.acknowledge( - id: acknowledgement.id, - generation: acknowledgement.generation, - sourceEpoch: acknowledgement.sourceEpoch, - sequence: acknowledgement.sequence - ) - } + return acknowledgements } private func buffer( @@ -1177,45 +1343,25 @@ package final class NetworkRequestStore { ) } - private func pruneConcreteQueryRegistrations(isolation: isolated (any Actor)) { - _ = isolation + private func pruneConcreteQueryRegistrations() { concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in registration.results.value != nil } } #if DEBUG - package func concreteQueryRegistrationCountForTesting( - isolation: isolated (any Actor) = #isolation - ) async -> Int { - _ = isolation + package nonisolated(nonsending) func concreteQueryRegistrationCountForTesting() async -> Int { return await queryIndex.queryRegistrationCountForTesting() } #endif - private func requestForResult( - _ id: NetworkRequest.ID, - isolation: isolated (any Actor) - ) -> NetworkRequest? { - _ = isolation + private func requestForResult(_ id: NetworkRequest.ID) -> NetworkRequest? { #if DEBUG performanceCounters.resultIdentityLookupCount += 1 #endif return requestsByID[id] } - private func pruneFetchedResults(isolation: isolated (any Actor)) { - _ = isolation - fetchedResults.removeAll { $0.value == nil } - } - - private func resetFetchedResults(isolation: isolated (any Actor)) { - pruneFetchedResults(isolation: isolation) - for registration in fetchedResults { - registration.value?.resetNetworkItems(indexSequence: queryIndexSequence) - } - } - private func skipEvent(_ reason: String) { WebInspectorDataKitLog.debug("event skipped: \(reason)") } 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..16cb9ba6 --- /dev/null +++ b/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift @@ -0,0 +1,97 @@ +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 !isClosed, let modelContext 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 !isClosed, let modelContext 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 !isClosed, let modelContext else { + throw WebInspectorModelError.staleModel + } + return try await modelContext.preview(of: object, objectGroup: self) + } + + public nonisolated(nonsending) func close() async throws { + guard !isClosed else { + return + } + defer { + isClosed = true + } + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + try await modelContext.close(objectGroup: self) + } +} diff --git a/Sources/WebInspectorDataKit/RuntimeStateStore.swift b/Sources/WebInspectorDataKit/RuntimeStateStore.swift index 6a0c0e19..9ec1c837 100644 --- a/Sources/WebInspectorDataKit/RuntimeStateStore.swift +++ b/Sources/WebInspectorDataKit/RuntimeStateStore.swift @@ -3,7 +3,7 @@ import WebInspectorProxyKit /// Owns Runtime execution-context identity, selection, remote-object identity, /// and local object membership. /// -/// `WebInspectorContext` remains the attachment and transport coordinator. It +/// `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. @@ -22,7 +22,7 @@ package final class RuntimeStateStore { } private enum ObjectOwner: Hashable { - case client + case group(RuntimeObjectGroup.ID) case console } @@ -37,6 +37,8 @@ package final class RuntimeStateStore { 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 = [:] @@ -45,6 +47,8 @@ package final class RuntimeStateStore { objectsByID = [:] nextSyntheticObjectOrdinal = 0 defaultExecutionGeneration = 0 + nextGroupOrdinal = 0 + activeGroupIDs = [] } package var executionContexts: [RuntimeContext] { @@ -67,25 +71,21 @@ package final class RuntimeStateStore { } package func select( - _ context: RuntimeContext?, - isolation: isolated (any Actor) = #isolation + _ context: RuntimeContext? ) { - _ = isolation guard let context else { selectedContextID = nil return } guard contextsByID[context.id] === context else { - preconditionFailure("RuntimeContext is not registered in this WebInspectorContext.") + preconditionFailure("RuntimeContext is not registered in this WebInspectorModelContext.") } selectedContextID = context.id } package func evaluationBinding( - for context: RuntimeContext?, - isolation: isolated (any Actor) = #isolation + for context: RuntimeContext? ) throws -> EvaluationBinding { - _ = isolation let executionContext: RuntimeContext? if let context { try requireRegisteredContext(context) @@ -103,44 +103,42 @@ package final class RuntimeStateStore { package func finishEvaluation( _ result: Runtime.EvaluationResult, binding: EvaluationBinding, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + groupID: RuntimeObjectGroup.ID ) throws -> RuntimeEvaluation { - _ = isolation + 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 WebInspectorContext." + "Runtime evaluation target is no longer current in this WebInspectorModelContext." ) } return RuntimeEvaluation( - object: register(result.object, owner: .client, modelContext: modelContext), + object: register(result.object, owner: .group(groupID)), isException: result.wasThrown ) } package func objectBinding( for object: RuntimeObject, - isolation: isolated (any Actor) = #isolation + groupID: RuntimeObjectGroup.ID ) throws -> ObjectBinding? { - _ = isolation - let object = try requireRegisteredObject(object) + 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, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + groupID: RuntimeObjectGroup.ID ) throws -> [RuntimeObject.Property] { - _ = isolation - try requireRegisteredObject(binding.object) + 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: .client, modelContext: modelContext) + value.id == nil ? nil : register(value, owner: .group(groupID)) } return RuntimeObject.Property( name: descriptor.name, @@ -153,33 +151,27 @@ package final class RuntimeStateStore { package func finishCollectionEntries( _ entries: [Runtime.CollectionEntry], binding: ObjectBinding, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + groupID: RuntimeObjectGroup.ID ) throws -> [RuntimeObject.Entry] { - _ = isolation - try requireRegisteredObject(binding.object) + try requireActiveGroup(groupID) + try requireRegisteredObject(binding.object, owner: .group(groupID)) return entries.map { entry in RuntimeObject.Entry( - key: entry.key.map { register($0, owner: .client, modelContext: modelContext) }, - value: register(entry.value, owner: .client, modelContext: modelContext) + key: entry.key.map { register($0, owner: .group(groupID)) }, + value: register(entry.value, owner: .group(groupID)) ) } } package func registerConsoleParameter( - _ payload: Runtime.RemoteObject, - modelContext: WebInspectorContext, - isolation: isolated (any Actor) = #isolation + _ payload: Runtime.RemoteObject ) -> RuntimeObject { - _ = isolation - return register(payload, owner: .console, modelContext: modelContext) + return register(payload, owner: .console) } package func removeConsoleOwnership( - from objects: [RuntimeObject], - isolation: isolated (any Actor) = #isolation + from objects: [RuntimeObject] ) { - _ = isolation for object in objects { guard var record = objectsByID[object.id], record.object === object else { continue @@ -194,9 +186,7 @@ package final class RuntimeStateStore { } package func removeAllConsoleOwnership( - isolation: isolated (any Actor) = #isolation ) { - _ = isolation for (id, var record) in objectsByID.map({ ($0.key, $0.value) }) { record.owners.remove(.console) if record.owners.isEmpty { @@ -209,10 +199,8 @@ package final class RuntimeStateStore { package func apply( _ event: Runtime.Event, - sourceTargetID: WebInspectorTarget.ID?, - isolation: isolated (any Actor) = #isolation + sourceTargetID: WebInspectorTarget.ID? ) { - _ = isolation switch event { case let .executionContextCreated(context): applyExecutionContextCreated(context) @@ -220,7 +208,7 @@ package final class RuntimeStateStore { applyExecutionContextDestroyed(id) case let .executionContextsCleared(eventTargetID): if eventTargetID == .currentPage || eventTargetID == sourceTargetID { - reset(isolation: isolation) + reset() } else { clear(targetID: eventTargetID) } @@ -229,15 +217,41 @@ package final class RuntimeStateStore { } } - package func reset(isolation: isolated (any Actor) = #isolation) { - _ = isolation + 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] { @@ -285,8 +299,7 @@ package final class RuntimeStateStore { private func register( _ payload: Runtime.RemoteObject, - owner: ObjectOwner, - modelContext: WebInspectorContext + owner: ObjectOwner ) -> RuntimeObject { let id: RuntimeObject.ID if let proxyID = payload.id { @@ -307,7 +320,7 @@ package final class RuntimeStateStore { return record.object } - let object = RuntimeObject(id: id, remoteObject: payload, modelContext: modelContext) + let object = RuntimeObject(id: id, remoteObject: payload) objectsByID[id] = ObjectRecord(object: object, owners: [owner]) return object } @@ -316,22 +329,33 @@ package final class RuntimeStateStore { private func requireRegisteredContext(_ context: RuntimeContext) throws -> RuntimeContext { guard contextsByID[context.id] === context else { throw WebInspectorProxyError.disconnected( - "RuntimeContext is not registered in this WebInspectorContext." + "RuntimeContext is not registered in this WebInspectorModelContext." ) } return context } @discardableResult - private func requireRegisteredObject(_ object: RuntimeObject) throws -> RuntimeObject { - guard objectsByID[object.id]?.object === object else { + 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 WebInspectorContext." + "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, diff --git a/Sources/WebInspectorDataKit/WebInspectorContainer.swift b/Sources/WebInspectorDataKit/WebInspectorContainer.swift deleted file mode 100644 index e959213c..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 = try await context.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 81327333..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ /dev/null @@ -1,2709 +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 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 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 store else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit context was released before DOM undo/redo.") - } - return try store.undoRedoTarget( - capturedTarget: target, - fallbackTarget: fallbackTarget, - documentEpoch: documentEpoch, - isolation: isolation - ) - } - } - - 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 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 - private let domState: DOMStateStore - private let runtimeState: RuntimeStateStore - /// 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 var rootNode: DOMNode? { - domState.rootNode - } - - /// The currently selected DOM node. - public var selectedNode: DOMNode? { - domState.selectedNode - } - - /// A Boolean value indicating whether WebKit inspect mode is enabled. - public var isElementPickerEnabled: Bool { - domState.isElementPickerEnabled - } - - /// Runtime execution contexts known to the current page. - public var executionContexts: [RuntimeContext] { - runtimeState.executionContexts - } - - /// The selected Runtime execution context. - public var selectedContext: RuntimeContext? { - runtimeState.selectedContext - } - - private var currentPage: WebInspectorTarget? - private var currentPageGeneration: Int - private var startupTask: Task? - private var currentPageRetargetTask: Task? - private var currentPageCleanupTask: Task? - private var documentReloadTask: 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 let statusRelay: WebInspectorAsyncStreamRelay - private let networkRequests: NetworkRequestStore - private let consoleMessages: ConsoleMessageStore - - /// 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 - domState = DOMStateStore() - runtimeState = RuntimeStateStore() - state = .attaching - teardownError = nil - currentPage = nil - currentPageGeneration = 0 - startupTask = nil - currentPageRetargetTask = nil - currentPageCleanupTask = nil - documentReloadTask = 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 - statusRelay = WebInspectorAsyncStreamRelay() - networkRequests = NetworkRequestStore() - consoleMessages = ConsoleMessageStore() - WebInspectorDataKitLog.debug("context state=\(state.logDescription)") - } - - package static func preview(isolation: isolated (any Actor)) -> WebInspectorContext { - let container = WebInspectorContainer(proxy: WebInspectorProxy(localStateOnly: ())) - 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(localStateOnly: ())) - let context = WebInspectorContext(container, isolation: isolation) - context.state = .detached - return context - } - - deinit { - startupTask?.cancel() - currentPageRetargetTask?.cancel() - currentPageCleanupTask?.cancel() - documentReloadTask?.cancel() - inspectedNodeHighlightTask?.cancel() - cancelFrameDocumentLoadTasks() - styleRefreshTask?.cancel() - for task in styleToggleTasks.values { - task.cancel() - } - stopEventPumps() - 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 - let networkReset = networkRequests.prepareResetForNewAttachment(isolation: isolation) - let networkStore = networkRequests - startupTask = Task { [ - weak self, - networkStore, - networkReset, - previousStartupTask, - previousCurrentPageCleanupTask, - ] in - _ = isolation - await networkStore.finishQueryIndexReset(networkReset, isolation: 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 domState.node(for: id, isolation: isolation) - } - - package func requiredNode(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> DOMNode { - requireOwner(isolation) - return try domState.requiredNode(for: id, isolation: isolation) - } - - /// Returns the registered Network request for an identifier. - public func registeredRequest( - for id: NetworkRequest.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - requireOwner(isolation) - return networkRequests.request(for: id, isolation: isolation) - } - - package var networkRequestsCollectionState: NetworkRequestCollectionState { - networkRequests.collectionState - } - - package func registeredRequest( - forProxyID id: Network.Request.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - requireOwner(isolation) - return networkRequests.request(forProxyID: id, isolation: isolation) - } - - /// Clears retained Network requests and emits reset transactions. - public func clearNetworkRequests(isolation: isolated (any Actor) = #isolation) async { - requireOwner(isolation) - await networkRequests.clear(isolation: isolation) - } - - /// Returns the registered Console message for an identifier. - public func registeredMessage( - for id: ConsoleMessage.ID, - isolation: isolated (any Actor) = #isolation - ) -> ConsoleMessage? { - requireOwner(isolation) - return consoleMessages.message(for: id, isolation: isolation) - } - - /// 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) - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - let effects = domState.select(node, reveal: reveal, isolation: isolation) - applyDOMStateEffects(effects, 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) - let deletion = try domState.sortedDeletionNodes(for: nodeIDs, isolation: isolation) - 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, - isolation: isolation - ), - isolation: isolation - ) - throw DOMDeletionPartialFailure( - deletedNodeCount: acceptedNodeIDs.count, - underlyingError: error - ) - } - throw error - } - } - applyDOMStateEffects( - domState.clearSelectionIfDeleted( - acceptedNodeIDs, - snapshot: deletion.snapshot, - isolation: isolation - ), - 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 domState.currentTreeSnapshot( - containing: [node], - isolation: isolation - ).selectorPath(for: node.id) - case .xPath: - return try domState.currentTreeSnapshot( - containing: [node], - isolation: isolation - ).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) - let deletion = try domState.sortedDeletionNodes(for: nodes, isolation: isolation) - 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, - isolation: isolation - ), - isolation: isolation - ) - throw DOMDeletionPartialFailure( - deletedNodeCount: removedNodes.count, - underlyingError: error - ) - } - throw error - } - } - applyDOMStateEffects( - domState.clearSelectionIfDeleted( - removedNodes.map(\.id), - snapshot: deletion.snapshot, - isolation: isolation - ), - 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 domState.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 { - domState.recordPageHighlight(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() - domState.clearPageHighlight(isolation: isolation) - } - - package func domUndoRedoCommands(isolation: isolated (any Actor) = #isolation) throws -> DOMUndoRedoCommands { - requireOwner(isolation) - return DOMUndoRedoCommands( - store: domState, - target: domState.capturedEditHistoryTarget(isolation: isolation), - fallbackTarget: currentPage, - documentEpoch: domState.documentEpoch - ) - } - - 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 - } - applyDOMStateEffects( - domState.setElementPickerEnabled(isEnabled, isolation: isolation), - isolation: isolation - ) - 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 domState.currentTreeSnapshot( - containing: [node], - isolation: isolation - ).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 domState.currentTreeSnapshot( - containing: [node], - isolation: isolation - ).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) - return try domState.treeController(root: requestedRoot, isolation: isolation) - } - - package func rootTreeController(isolation: isolated (any Actor) = #isolation) -> DOMTreeController { - requireOwner(isolation) - return domState.rootTreeController(isolation: isolation) - } - - /// Selects the Runtime execution context used by default evaluation calls. - public func selectContext(_ context: RuntimeContext?, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - runtimeState.select(context, isolation: isolation) - } - - /// 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) - let binding = try runtimeState.evaluationBinding(for: context, isolation: isolation) - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let result = try await currentPage.runtime.evaluate(expression, in: binding.executionContextID) - return try runtimeState.finishEvaluation( - result, - binding: binding, - modelContext: self, - isolation: isolation - ) - } - - /// 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 func networkRequests( - matching query: NetworkQuery = NetworkQuery(), - isolation: isolated (any Actor) = #isolation - ) async throws -> WebInspectorFetchedResults { - requireOwner(isolation) - return try await networkRequests.results( - matching: query, - modelContext: self, - isolation: isolation - ) - } - - /// 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 func consoleMessages( - matching query: ConsoleQuery = ConsoleQuery(), - isolation: isolated (any Actor) = #isolation - ) async throws -> WebInspectorFetchedResults { - requireOwner(isolation) - return try await consoleMessages.results( - matching: query, - modelContext: self, - isolation: isolation - ) - } - - func updateNetworkQuery( - _ query: NetworkQuery, - for results: WebInspectorFetchedResults, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - guard results.modelContext === self else { - preconditionFailure("Network fetched results are not registered in this WebInspectorContext.") - } - try await networkRequests.update(query, for: results, isolation: isolation) - } - - func updateConsoleQuery( - _ query: ConsoleQuery, - for results: WebInspectorFetchedResults, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - guard results.modelContext === self else { - preconditionFailure("Console fetched results are not registered in this WebInspectorContext.") - } - try await consoleMessages.update(query, for: results, isolation: isolation) - } - - /// 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.") - } - networkRequests.register(networkResults, modelContext: self, isolation: isolation) - case .consoleMessages: - guard let consoleResults = results as? WebInspectorFetchedResults else { - preconditionFailure("ConsoleMessage descriptors can only fetch ConsoleMessage models.") - } - consoleMessages.register(consoleResults, modelContext: self, isolation: isolation) - } - 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 - ) - } - - 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.") - } - networkRequests.updateFetchDescriptor( - networkDescriptor, - for: networkResults, - modelContext: self, - isolation: isolation - ) - case .consoleMessages: - guard let consoleDescriptor = descriptor as? WebInspectorFetchDescriptor, - let consoleResults = results as? WebInspectorFetchedResults else { - preconditionFailure("ConsoleMessage descriptors can only update ConsoleMessage fetched results.") - } - consoleMessages.updateFetchDescriptor( - consoleDescriptor, - for: consoleResults, - modelContext: self, - isolation: isolation - ) - } - } - - 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, - isolation: isolation - ) - return - } - - do { - let body = try await currentPage.network.responseBody( - for: request.proxyID, - backendResourceIdentifier: request.backendResourceIdentifier - ) - finishResponseBodyFetch( - .success(body), - for: request, - expectedBody: expectedBody, - isolation: isolation - ) - } catch let error as WebInspectorProxyError { - finishResponseBodyFetch( - .failure(error), - for: request, - expectedBody: expectedBody, - isolation: isolation - ) - } catch { - finishResponseBodyFetch( - .failure(.commandFailed( - domain: "Network", - method: "getResponseBody", - message: String(describing: error) - )), - for: request, - expectedBody: expectedBody, - isolation: isolation - ) - } - } - - private func finishResponseBodyFetch( - _ result: Result, - for request: NetworkRequest, - expectedBody: NetworkBody, - isolation: isolated (any Actor) - ) { - networkRequests.finishResponseBodyFetch( - result, - for: request, - expectedBody: expectedBody, - isolation: isolation - ) - } - - func requestChildren( - for node: DOMNode, - depth: Int, - isolation: isolated (any Actor) = #isolation - ) async { - requireOwner(isolation) - guard (try? domState.registeredNode(node, isolation: isolation)) != nil 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) - guard let binding = try runtimeState.objectBinding(for: object, isolation: isolation) else { - return [] - } - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let descriptors = try await currentPage.runtime.properties(of: binding.remoteID) - return try runtimeState.finishProperties( - descriptors, - binding: binding, - modelContext: self, - isolation: isolation - ) - } - - func collectionEntries( - for object: RuntimeObject, - isolation: isolated (any Actor) = #isolation - ) async throws -> [RuntimeObject.Entry] { - requireOwner(isolation) - guard let binding = try runtimeState.objectBinding(for: object, isolation: isolation) else { - return [] - } - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let entries = try await currentPage.runtime.collectionEntries(of: binding.remoteID) - return try runtimeState.finishCollectionEntries( - entries, - binding: binding, - modelContext: self, - isolation: isolation - ) - } - - /// 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 - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - cancelFrameDocumentLoadTasks() - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - for task in styleToggleTasks.values { - task.cancel() - } - styleToggleTasks = [:] - stopEventPumps() - currentPage = nil - advanceCurrentPageGeneration(isolation: isolation) - domState.advanceDocumentEpoch(isolation: isolation) - await 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 - } - await resetReplayBackedModelsBeforeEnable(isolation: isolation) - 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, - expectedEpoch: document.generation, - 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 = domState.documentEpoch - let document = try await target.dom.getDocument() - guard Task.isCancelled == false else { - throw CancellationError() - } - guard generation == domState.documentEpoch else { - continue - } - return (node: document, generation: generation) - } - } - - private func reloadDOMDocumentIfNeeded( - _ document: LoadedDOMDocument, - on target: WebInspectorTarget, - isolation: isolated (any Actor) - ) async throws -> LoadedDOMDocument { - if document.generation == domState.documentEpoch { - return document - } - return try await loadCurrentDOMDocument(on: target, isolation: isolation) - } - - private func resetReplayBackedModelsBeforeEnable( - isolation: isolated (any Actor) - ) async { - runtimeState.reset(isolation: isolation) - await consoleMessages.resetForReplay(modelContext: self, isolation: isolation) - } - - private func prepareCurrentPageStreamReset( - isolation: isolated (any Actor) - ) -> ConsoleMessageStore.QueryIndexReset { - runtimeState.reset(isolation: isolation) - let preparation = consoleMessages.prepareClearForLifecycle( - modelContext: self, - isolation: isolation - ) - applyConsoleMessageEffects(preparation.effects, isolation: isolation) - return preparation.queryIndexReset - } - - private func prepareCurrentPageLifecycleReset( - isolation: isolated (any Actor) - ) -> ConsoleMessageStore.QueryIndexReset { - resetDOM(isolation: isolation) - return prepareCurrentPageStreamReset(isolation: isolation) - } - - private func finishCurrentPageLifecycleReset( - _ reset: ConsoleMessageStore.QueryIndexReset, - isolation: isolated (any Actor) - ) async { - await consoleMessages.finishQueryIndexReset(reset, isolation: isolation) - } - - private func resetAttachmentBackedModels(isolation: isolated (any Actor)) async { - let consoleReset = prepareCurrentPageLifecycleReset(isolation: isolation) - let networkReset = networkRequests.prepareResetForNewAttachment(isolation: isolation) - await finishCurrentPageLifecycleReset(consoleReset, isolation: isolation) - await networkRequests.finishQueryIndexReset(networkReset, isolation: isolation) - } - - 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 } - await 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 - } - - @discardableResult - private func registeredNode( - _ node: DOMNode, - isolation: isolated (any Actor) = #isolation - ) throws -> DOMNode { - try domState.registeredNode(node, isolation: isolation) - } - - 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, - isolation: isolated (any Actor) = #isolation - ) { - domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - } - - 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 isCurrentPageGeneration( - _ generation: Int, - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation - return currentPageGeneration == generation - } - - @discardableResult - private func advanceCurrentPageGeneration(isolation: isolated (any Actor)) -> Int { - _ = isolation - currentPageGeneration += 1 - return currentPageGeneration - } - - private func fail(_ error: WebInspectorProxyError) { - switch state { - case .failed, .detached: - return - case .attaching, .attached: - // A terminal failure preserves the last complete model snapshot for - // diagnosis. Explicit stop/detach owns semantic model clearing. - 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)") - } - - /// 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, .protocolViolation, - .transportFailure: - fail(proxyError) - case .closed, .pageUnavailable: - WebInspectorDataKitLog.debug("\(operation) raced connection close") - case .staleIdentifier, .commandFailed, .commandRejected, - .eventBufferOverflow, .connectionInUse, .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) - } - - private func applyDOMStateEffects( - _ effects: DOMStateStore.Effects, - isolation: isolated (any Actor) - ) { - if effects.documentReset { - documentReloadTask?.cancel() - documentReloadTask = nil - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - cancelFrameDocumentLoadTasks() - } - - if effects.selectionChanged || effects.documentReset { - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - } - effects.discardedStyleNode?.setElementStyles(nil) - - if effects.statusChanged { - notifyStatusChanged() - } - if effects.selectionChanged { - refreshSelectedStyles(isolation: isolation) - } else if effects.selectedStylesNeedRefresh { - markSelectedStylesNeedsRefresh(isolation: isolation) - } - - if effects.shouldClearPageHighlight { - clearPageHighlightForDOMReset(isolation: isolation) - } - for request in effects.frameDocumentLoadRequests { - loadFrameDocumentIfNeeded( - forFrameTargetID: request.targetID, - reason: request.reason, - isolation: isolation - ) - } - if let inspectedNode = effects.inspectedNode { - restoreElementPickerHighlight(for: inspectedNode, isolation: isolation) - } - if effects.shouldReloadDocument, state != .attaching { - reloadDocument(isolation: isolation) - } - } - - func reloadDocument(isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - guard let currentPage else { - skipEvent("reloadDocument ignored: no current page target") - return - } - - let generation = domState.documentEpoch - 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?.domState.documentEpoch == generation else { - return - } - self?.applyDocument( - document, - expectedEpoch: generation, - 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) - domState.advanceDocumentEpoch(isolation: isolation) - for task in styleToggleTasks.values { - task.cancel() - } - styleToggleTasks = [:] - - let lifecycleReset = prepareCurrentPageLifecycleReset(isolation: isolation) - - currentPageRetargetTask = Task { [weak self, refreshedTarget, generation, lifecycleReset] in - _ = isolation - guard let self else { - return - } - await self.finishCurrentPageLifecycleReset(lifecycleReset, isolation: isolation) - guard self.isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - 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, - expectedEpoch: document.generation, - 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 { - applyDOMStateEffects( - domState.detachProjectedFrameDocument(forFrameID: frame.id, isolation: isolation), - isolation: isolation - ) - return - } - domState.advanceDocumentEpoch(isolation: isolation) - resetDOM(isolation: isolation) - runtimeState.reset(isolation: isolation) - guard currentPageRetargetTask == nil, - state != .attaching else { - return - } - reloadDocument(isolation: isolation) - } - - private func applyCurrentPageFrameDetached( - _ frameID: FrameID, - isolation: isolated (any Actor) - ) { - applyDOMStateEffects( - domState.detachProjectedFrameDocument(forFrameID: frameID, isolation: isolation), - 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 = [:] - let generation = advanceCurrentPageGeneration(isolation: isolation) - domState.advanceDocumentEpoch(isolation: isolation) - let streamReset = prepareCurrentPageStreamReset(isolation: isolation) - - currentPageRetargetTask = Task { [weak self, generation, streamReset] in - _ = isolation - guard let self else { - return - } - await self.finishCurrentPageLifecycleReset(streamReset, isolation: isolation) - guard Task.isCancelled == false, - self.isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - 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 - resetDOM(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 - resetDOM(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) - let effects = domState.apply(event, modelContext: self, isolation: isolation) - applyDOMStateEffects(effects, isolation: isolation) - } - - func applyDocument( - _ node: DOM.Node, - expectedEpoch: Int, - reason: DOMTreeSnapshotReason = .initialDocument, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - guard let effects = domState.applyDocument( - node, - expectedEpoch: expectedEpoch, - reason: reason, - modelContext: self, - isolation: isolation - ) else { - return - } - applyDOMStateEffects(effects, isolation: isolation) - } - - package func seedDOMDocument( - _ node: DOM.Node, - isolation: isolated (any Actor) = #isolation - ) { - let reason: DOMTreeSnapshotReason = rootNode == nil ? .initialDocument : .documentUpdated - applyDocument( - node, - expectedEpoch: domState.documentEpoch, - reason: reason, - isolation: isolation - ) - } - - package func seedElementPickerEnabled( - _ isEnabled: Bool, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - applyDOMStateEffects( - domState.setElementPickerEnabled(isEnabled, isolation: isolation), - isolation: isolation - ) - } - - /// 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 = domState.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)) { - let effects = domState.resetDocument(isolation: isolation) - applyDOMStateEffects(effects, isolation: isolation) - } - - private func clearPageHighlightForDOMReset(isolation: isolated (any Actor)) { - guard let currentPage else { - return - } - inspectedNodeHighlightTask = Task { [weak self, currentPage] in - _ = isolation - do { - guard Task.isCancelled == false else { - return - } - guard self?.domState.shouldSendPageHighlightClearAfterReset(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 loadFrameDocumentIfNeeded( - forFrameTargetID frameTargetID: WebInspectorTarget.ID, - reason: String, - isolation: isolated (any Actor) - ) { - guard frameDocumentLoadTasks[frameTargetID] == nil else { - return - } - let epoch = domState.documentEpoch - let target = proxy.frameTarget(id: frameTargetID) - WebInspectorDataKitLog.debug( - "frame document projection loading target=\(frameTargetID.rawValue) reason=\(reason)" - ) - frameDocumentLoadTasks[frameTargetID] = Task { [weak self, target, frameTargetID, epoch] in - _ = isolation - do { - let document = try await target.dom.getDocument() - guard Task.isCancelled == false, - self?.domState.documentEpoch == epoch, - let self, - let effects = self.domState.applyFrameDocument( - document, - frameTargetID: frameTargetID, - expectedEpoch: epoch, - modelContext: self, - isolation: isolation - ) else { - return - } - self.applyDOMStateEffects(effects, isolation: isolation) - } catch is CancellationError { - return - } catch { - guard self?.domState.documentEpoch == epoch else { - return - } - self?.failIfTerminal(error, operation: "frame DOM.getDocument") - } - if self?.domState.documentEpoch == epoch { - self?.frameDocumentLoadTasks[frameTargetID] = nil - } - } - } - - private func cancelFrameDocumentLoadTasks() { - for task in frameDocumentLoadTasks.values { - task.cancel() - } - frameDocumentLoadTasks = [:] - } - - 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 epoch = domState.documentEpoch - 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, epoch, nodeID] in - _ = isolation - do { - guard Task.isCancelled == false, - self?.domState.documentEpoch == epoch else { - return - } - WebInspectorDataKitLog.debug( - "DOM.inspect restoring highlight nodeID=\(String(describing: nodeID))" - ) - self?.domState.recordPageHighlight(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 = domState.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 && domState.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 = domState.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 domState.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 domState.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 = domState.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) - domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - 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 = domState.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) - domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - 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) - domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - 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) - domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - 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 = domState.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.domState.recordEditHistoryTarget(target, options: options, isolation: isolation) - 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 domState.selectedNode?.id == nodeID else { - return - } - markSelectedStylesNeedsRefresh(isolation: isolation) - } - - private func markSelectedStylesNeedsRefresh(isolation: isolated (any Actor) = #isolation) { - styleRefreshGeneration += 1 - guard let styles = domState.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) - 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, - isolation: isolation - ) - } - - 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) - networkRequests.seedResponseBody( - for: requestID, - body: body, - base64Encoded: base64Encoded, - size: size, - isTruncated: isTruncated, - isolation: isolation - ) - } - - package func apply( - _ event: Network.Event, - isolation: isolated (any Actor) = #isolation - ) async { - requireOwner(isolation) - await networkRequests.apply(event, modelContext: self, isolation: isolation) - } -} - -extension WebInspectorContext { - func apply( - _ event: Console.Event, - targetID: WebInspectorTarget.ID? = nil, - isolation: isolated (any Actor) = #isolation - ) async { - requireOwner(isolation) - let effects = await consoleMessages.apply( - event, - targetID: targetID, - modelContext: self, - registerRuntimeObject: { [self] payload in - runtimeState.registerConsoleParameter( - payload, - modelContext: self, - isolation: isolation - ) - }, - isolation: isolation - ) - // 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, isolation: isolation) - } - - private func applyConsoleMessageEffects( - _ effects: ConsoleMessageStore.Effects, - isolation: isolated (any Actor) - ) { - if effects.clearedAllMessages { - runtimeState.removeAllConsoleOwnership(isolation: isolation) - return - } - runtimeState.removeConsoleOwnership( - from: effects.runtimeObjectsToUnregister, - isolation: isolation - ) - } - -} - -extension WebInspectorContext { - func apply( - _ event: Runtime.Event, - targetID: WebInspectorTarget.ID? = nil, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - runtimeState.apply(event, sourceTargetID: targetID, isolation: isolation) - } -} 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/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/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift new file mode 100644 index 00000000..20553080 --- /dev/null +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -0,0 +1,3274 @@ +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 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 } + } + + 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 .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) + case feedBufferOverflow(capacity: Int) + } + + 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 + ) + case network(NetworkRequestStore.IndexWork) + case console(ConsoleMessageStore.IndexWork) + case networkAcknowledgement(NetworkRequestStore.IndexAcknowledgementWork) + case consoleAcknowledgement(ConsoleMessageStore.IndexAcknowledgementWork) + case acknowledgements( + network: NetworkRequestStore.IndexAcknowledgementWork?, + console: ConsoleMessageStore.IndexAcknowledgementWork? + ) + 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): + return ReducerWorkResult( + commit: commit, + output: .reset( + network: await network.run(), + console: await console.run() + ) + ) + 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): + await network?.run() + await console?.run() + 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 + )) + ) + } + } + } + + 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 + ) + 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 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 + 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 { + 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) + } + + public nonisolated(nonsending) func close() async { + await tearDown(terminal: true) + } + + private func bindOwner(_ isolation: isolated (any Actor)) { + deliveryBridge.bind(self, isolation: isolation) + } + + 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)) + } + + do { + let feed = try await proxy.openModelFeed( + configuredDomains: configuredDomains + ) + guard !Task.isCancelled else { + return await closeUnacceptedFeed(feed) + } + let accepted = await accept( + feed: feed, + proxy: proxy, + token: token, + bridge: bridge + ) + if !accepted { + return await closeUnacceptedFeed(feed) + } + return nil + } catch is CancellationError { + return nil + } catch { + await failAttachment( + mapAttachmentFailure(error), + token: token, + bridge: bridge + ) + return nil + } + } + + private nonisolated static func closeUnacceptedFeed( + _ feed: ConnectionModelFeed + ) async -> Failure? { + do { + try await feed.close() + return nil + } catch { + return mapAttachmentFailure(error) + } + } + + 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 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 + attachmentTask = nil + + let records = feed.records + let bridge = deliveryBridge + driverTask = Self.makeDriverTask( + records, + token: token, + bridge: bridge + ) + 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): + followup = .acknowledgements( + network: networkRequests.commit(networkResult), + console: consoleMessages.commit(consoleResult) + ) + 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 { + 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): + return .acknowledgements( + network: networkRequests.commit(networkResult), + console: consoleMessages.commit(consoleResult) + ) + case .none, .network, .console, .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() + applyDOMStateEffects( + domState.resetDocument() + ) + 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) + ) + } + + 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 + if target.id == binding.currentPageID { + domState.advanceDocumentEpoch() + applyDOMStateEffects( + domState.resetDocument() + ) + } else if let frameID = target.frameID { + applyDOMStateEffects( + domState.detachProjectedFrameDocument( + forFrameID: frameID + ) + ) + } else { + return prepareProtocolFailure( + "A non-page DOM target had no frame identity.", + token: token + ) + } + return PreparedReducerStep(work: .none, 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) + 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) + 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 + ) + 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 let .bufferOverflow(capacity): + return .feedBufferOverflow(capacity: capacity) + 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 .feedBufferOverflow(capacity: 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 { + 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 { + 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. + public nonisolated(nonsending) func responseBody( + for request: NetworkRequest + ) async throws -> NetworkBody { + try requireConfigured(.network) + guard networkRequests.request(for: request.id) === request else { + throw WebInspectorModelError.staleModel + } + let body = request.responseBody + switch body.phase { + case .loaded: + return body + case let .failed(error): + throw error + case .fetching: + throw WebInspectorModelError.commandRejected( + method: "Network.getResponseBody", + message: "A response-body fetch is already in progress." + ) + case .available: + guard request.canFetchResponseBody else { + throw WebInspectorModelError.commandRejected( + method: "Network.getResponseBody", + message: "The response body is not available for this request." + ) + } + } + let page = try currentPageOrThrow() + body.markFetching() + do { + let payload = try await page.network.responseBody( + for: request.proxyID, + backendResourceIdentifier: request.backendResourceIdentifier + ) + networkRequests.finishResponseBodyFetch( + .success(payload), + for: request, + expectedBody: body + ) + return body + } catch let error as WebInspectorProxyError { + networkRequests.finishResponseBodyFetch( + .failure(error), + for: request, + expectedBody: body + ) + throw error + } catch { + let proxyError = WebInspectorProxyError.commandFailed( + domain: "Network", + method: "getResponseBody", + message: String(describing: error) + ) + networkRequests.finishResponseBodyFetch( + .failure(proxyError), + for: request, + expectedBody: body + ) + throw proxyError + } + } + + nonisolated(nonsending) func updateNetworkQuery( + _ query: NetworkQuery, + for results: WebInspectorFetchedResults + ) async throws { + 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 { + guard results.modelContext === self else { + preconditionFailure("Console fetched results are not registered in this WebInspectorModelContext.") + } + try await consoleMessages.update(query, for: results) + } + + func fetchResponseBody( + for request: NetworkRequest, + expectedBody: NetworkBody + ) async { + 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 + ) { + networkRequests.finishResponseBodyFetch( + result, + for: request, + expectedBody: expectedBody + ) + } + + 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 { + guard objectGroup.modelContext === self else { + throw WebInspectorModelError.staleModel + } + defer { + runtimeState.invalidateGroup(objectGroup.id) + } + guard isCurrent(objectGroup) else { + return + } + do { + try await objectGroup.target.runtime.releaseObjectGroup( + objectGroup.wireGroup + ) + } catch WebInspectorProxyError.staleIdentifier { + return + } + } + + private func validate(_ objectGroup: RuntimeObjectGroup) throws { + 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 { + 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() { + let effects = domState.resetDocument() + applyDOMStateEffects(effects) + } +} +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 { + 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 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.id), + let intent = styles.setStyleTextIntent( + for: property.id, + 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) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + styles.applySetStyleText(result: result, for: property.id) + 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.id), + let intent = styles.setDeclarationTextIntent( + for: property.id, + 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) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + styles.applySetStyleText(result: result, for: property.id) + return makeDOMUndoCapability(policy: undo) + } + + 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 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 + ) { + runtimeState.apply(event, sourceTargetID: targetID) + } +} diff --git a/Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift b/Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift deleted file mode 100644 index a1784cf1..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorMutationOptions.swift +++ /dev/null @@ -1,39 +0,0 @@ -/// 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 -} 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/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/DOM.swift b/Sources/WebInspectorProxyKit/DOM.swift index e8a85b37..7a6d440e 100644 --- a/Sources/WebInspectorProxyKit/DOM.swift +++ b/Sources/WebInspectorProxyKit/DOM.swift @@ -165,14 +165,90 @@ public struct DOM: Sendable, WebInspectorEventDomainHandle { ) } - /// Enables or disables WebKit's element picker. - public func setInspectMode(enabled: Bool) async throws { + package func setInspectMode(enabled: Bool) async throws { try await dispatchVoid( method: "setInspectModeEnabled", payload: SetInspectModeEnabledPayload(enabled: enabled) ) } + /// 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() + } + + let operationResult: Result + do { + operationResult = .success( + try await operation(pair.stream) + ) + } catch { + operationResult = .failure(error) + } + + 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( 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/Inspector.swift b/Sources/WebInspectorProxyKit/Inspector.swift index 7076c76b..9a921dab 100644 --- a/Sources/WebInspectorProxyKit/Inspector.swift +++ b/Sources/WebInspectorProxyKit/Inspector.swift @@ -1,7 +1,8 @@ import Foundation -package struct Inspector: Sendable, WebInspectorDomainHandle { +package struct Inspector: Sendable, WebInspectorEventDomainHandle { package static let commandDomain = WebInspectorProxyDomain.inspector + package static let eventDomain = WebInspectorProxyEventDomain.inspector package let endpoint: DomainEndpoint @@ -9,6 +10,15 @@ package struct Inspector: Sendable, WebInspectorDomainHandle { self.endpoint = endpoint } + package static func extractEvent( + _ event: WebInspectorProxyEvent + ) -> Event? { + guard case let .inspector(value) = event else { + return nil + } + return value + } + package func enable() async throws { try await dispatchVoid( method: "enable", diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index 46a2ebaa..e2133b78 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -449,11 +449,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) @@ -480,6 +476,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"): diff --git a/Sources/WebInspectorProxyKit/Runtime.swift b/Sources/WebInspectorProxyKit/Runtime.swift index 25e2510f..5d82c84c 100644 --- a/Sources/WebInspectorProxyKit/Runtime.swift +++ b/Sources/WebInspectorProxyKit/Runtime.swift @@ -52,11 +52,16 @@ public struct Runtime: Sendable, WebInspectorEventDomainHandle { /// Evaluates a JavaScript expression in an execution context. public func evaluate( _ expression: String, - in context: ExecutionContext.ID? = nil + in context: ExecutionContext.ID? = nil, + objectGroup: ObjectGroup? = nil ) async throws -> EvaluationResult { try await dispatch( method: "evaluate", - payload: EvaluatePayload(expression: expression, context: context), + payload: EvaluatePayload( + expression: expression, + context: context, + objectGroup: objectGroup + ), returning: EvaluationResult.self ) } @@ -125,10 +130,16 @@ public struct Runtime: Sendable, WebInspectorEventDomainHandle { 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 } } diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift index f5ad61c6..ba05e5d4 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift @@ -9,6 +9,7 @@ struct ConnectionCapabilityKey: Hashable, Sendable { enum ConnectionCapabilityLeaseOwner: Hashable, Sendable { case eventScope(WebInspectorProxyEventScopeID) case modelFeed(ConnectionModelFeedID, ModelDomain) + case modelElementPicker(ConnectionModelFeedID) } struct ConnectionEventScopeRegistry { diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index 11b049f7..c4a01627 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -128,6 +128,7 @@ package enum ModelTargetLifecycleEvent: Sendable { 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) @@ -457,6 +458,14 @@ package actor ConnectionModelFeed { 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/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index f8604ce9..d0a01992 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -177,7 +177,7 @@ private struct ConnectionCommandInvalidationEffects: Sendable { private struct ConnectionCapabilityTask: Sendable { let task: Task - let pendingReplyOwnership: ConnectionPendingReplyOwnership? + var pendingReplyOwnership: ConnectionPendingReplyOwnership? } private struct ConnectionModelBootstrapTask: Sendable { @@ -242,6 +242,7 @@ private struct ConnectionModelFeedRegistration: Sendable { let mailbox: ConnectionModelFeedMailbox var lifecycle: ConnectionModelFeedLifecycle var capabilityLeases: [ConnectionModelFeedCapabilityLease] + var elementPickerLease: ConnectionModelFeedCapabilityLease? var targetSnapshotThrough: UInt64? var resetGeneration: WebInspectorPage.Generation var synchronization: ConnectionModelFeedSynchronizationState? @@ -272,6 +273,43 @@ private struct EventEmissionEffects: Sendable { 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 @@ -380,6 +418,12 @@ package actor ConnectionCore { 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] @@ -440,6 +484,8 @@ package actor ConnectionCore { modelFeed = nil replayWasTaintedByDirectConsumer = false capabilities = ConnectionCapabilityRegistry() + elementPickerModes = [:] + inspectorInitializedGeneration = [:] capabilityTasks = [:] nextModelBootstrapOperationID = 0 modelBootstrapTasks = [:] @@ -598,6 +644,7 @@ package actor ConnectionCore { mailbox: mailbox, lifecycle: .acquiring, capabilityLeases: [], + elementPickerLease: nil, targetSnapshotThrough: nil, resetGeneration: resetGeneration, synchronization: nil, @@ -693,6 +740,8 @@ package actor ConnectionCore { } let completion = ReplyPromise() + let elementPickerLease = registration.elementPickerLease + registration.elementPickerLease = nil registration.lifecycle = .closing(completion) modelFeed = registration let commandInvalidation = invalidateModelCommands( @@ -704,6 +753,15 @@ package actor ConnectionCore { 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) + } + } for lease in registration.capabilityLeases.reversed() { do { try await releaseCapabilityLease(lease.owner, for: lease.key) @@ -761,6 +819,137 @@ package actor ConnectionCore { 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)? { @@ -913,22 +1102,20 @@ package actor ConnectionCore { for: key, activation: activation ) - } catch is CancellationError { - let cancellation = CancellationError() + if domain == .inspector { + try await acquireElementPickerMode(leaseOwner, for: key) + } + } catch { + let operationError = error do { try await releaseEventScope(scopeID) } catch { throw WebInspectorScopeError( - operationError: cancellation, + operationError: operationError, cleanupError: error ) } - throw cancellation - } catch { - eventScopes.remove(scopeID)?.sink?.finish(nil) - resumeEventScopeRegistrationWaitersIfNeeded() - await abandonCapabilityLease(leaseOwner, for: key) - throw error + throw operationError } return WebInspectorProxyEventScope(id: scopeID, events: stream) @@ -942,7 +1129,14 @@ package actor ConnectionCore { entry.sink?.finish(nil) let key = entry.capability - try await releaseCapabilityLease(.eventScope(id), for: key) + let owner = ConnectionCapabilityLeaseOwner.eventScope(id) + if key.domain == .inspector { + if let error = await releaseElementPickerResources(owner, for: key) { + throw error + } + return + } + try await releaseCapabilityLease(owner, for: key) } package nonisolated func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { @@ -2411,7 +2605,10 @@ package actor ConnectionCore { } capability.failedLeaseOwners.remove(leaseOwner) capability.activatedLeaseOwners.remove(leaseOwner) - capability.activationWaiters.removeValue(forKey: leaseOwner) + let activationWaiter = capability.activationWaiters.removeValue( + forKey: leaseOwner + ) + activationWaiter?.fulfill(.failure(CancellationError())) guard case .open = state else { capabilities.states[key] = capability @@ -2469,6 +2666,283 @@ package actor ConnectionCore { 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, + admission: ConnectionDirectCommandAdmission( + bindingGeneration: generation, + documentEpoch: modelDocumentEpoch(for: targetID) + ) + ) + } + + 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 @@ -2599,13 +3073,26 @@ package actor ConnectionCore { } let task = Task { [weak self, operation] in - let result: Result + let wireResult: Result do { try await operation.value() - result = .success(()) + wireResult = .success(()) } catch { - result = .failure(Self.mapCapabilityError(error, action: action, domain: key.domain)) + 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, @@ -2634,6 +3121,20 @@ package actor ConnectionCore { 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 + ) throws -> ConnectionOwnedCommandOperation { let targetID: ProtocolTarget.ID switch key.route.storage { case let .target(rawValue): @@ -2715,6 +3216,71 @@ package actor ConnectionCore { ) } + 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 + ) + } 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, @@ -2744,6 +3310,9 @@ package actor ConnectionCore { 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() @@ -2917,6 +3486,43 @@ package actor ConnectionCore { } } + private nonisolated static func mapElementPickerModeError( + _ error: any Swift.Error, + enabled: Bool + ) -> any Swift.Error { + if let proxyError = error as? WebInspectorProxyError { + return proxyError + } + 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)" + ) + } + } + /// 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 @@ -3931,6 +4537,14 @@ package actor ConnectionCore { 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 } @@ -4302,10 +4916,25 @@ package actor ConnectionCore { var terminalViolation: String? let targetSnapshot = snapshot() let sinks = eventScopes.sinks(for: eventDomain).filter { sink in - ConnectionEventProjection.shouldDeliver( + 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] = [:] @@ -4435,8 +5064,25 @@ package actor ConnectionCore { } 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 else { + || configuredDomain.map(registration.configuredDomains.contains) == true + || isElementPickerEvent else { return } guard ConnectionEventProjection.shouldDeliver( @@ -4509,8 +5155,11 @@ package actor ConnectionCore { throw TransportSession.Error.malformedMessage } payload = .runtime(target: target, event: value) - case .inspector: - payload = nil + case let .inspector(value): + guard isElementPickerEvent else { + return + } + payload = .inspector(target: target, event: value) } guard let payload else { diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index 98e6befe..e3a78419 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -285,39 +285,11 @@ public actor WebInspectorProxy { 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, - route: route, - continuation: continuation - ) - } - } - await group.waitForAll() - continuation.finish() - } - } - continuation.onTermination = { _ in - task.cancel() + eventStream(targetID: targetID, route: route, domain: .dom) { event in + guard case let .dom(value) = event else { + return nil } + return value } } @@ -447,53 +419,6 @@ public actor WebInspectorProxy { } } - private nonisolated func emitDOMInspectEvent( - for event: Inspector.Event, - route: RoutingTargetID, - continuation: AsyncStream.Continuation - ) async { - guard case let .inspect(object, _) = 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 - } - logger.debug( - "Inspector.inspect resolving route=\(Self.logDescription(route), privacy: .public) objectID=\(objectID.rawValue, privacy: .public)" - ) - // Inspector.inspect is a page-target event. WebInspectorUI resolves it - // through the main page DOM agent, whose node namespace is unscoped. - do { - let nodeID: DOM.Node.ID = try await dispatchCommand( - targetID: .currentPage, - route: .currentPage, - domain: .dom, - method: "requestNode", - payload: DOM.RequestNodePayload(objectID: objectID) - ) - logger.debug( - "Inspector.inspect resolved objectID=\(objectID.rawValue, privacy: .public) nodeID=\(nodeID.rawValue, privacy: .public)" - ) - continuation.yield(.inspect(nodeID)) - } catch { - logger.debug( - "Inspector.inspect requestNode failed objectID=\(objectID.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)" - ) - } - } - - 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, diff --git a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index a2568e81..566d17a5 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -85,6 +85,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { 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 { @@ -100,7 +101,8 @@ 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 @@ -109,6 +111,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { proxyReference = WebInspectorProxyReference(proxy) self.route = route self.pageBindingID = pageBindingID + self.authority = authority } private init( @@ -118,7 +121,8 @@ public struct WebInspectorTarget: Identifiable, Sendable { isProvisional: Bool, proxyReference: WebInspectorProxyReference, route: RoutingTargetID, - pageBindingID: String? + pageBindingID: String?, + authority: WebInspectorCommandAuthority ) { self.id = id self.kind = kind @@ -127,6 +131,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { self.proxyReference = proxyReference self.route = route self.pageBindingID = pageBindingID + self.authority = authority } package func withPageBinding(from lifecycleTarget: WebInspectorLifecycleTarget) -> WebInspectorTarget { @@ -137,7 +142,8 @@ public struct WebInspectorTarget: Identifiable, Sendable { isProvisional: lifecycleTarget.isProvisional, proxyReference: proxyReference, route: route, - pageBindingID: lifecycleTarget.pageBindingID + pageBindingID: lifecycleTarget.pageBindingID, + authority: authority ) } @@ -207,7 +213,7 @@ public struct WebInspectorTarget: Identifiable, Sendable { proxyReference: proxyReference, targetID: id, route: route, - authority: .direct + authority: authority ) } } @@ -223,4 +229,19 @@ package extension WebInspectorProxy { route: RoutingTargetID(id.rawValue) ) } + + 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) + ) + } } diff --git a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift index 5377b644..0912599d 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -3,22 +3,23 @@ import Observation import UIKit import WebKit import WebInspectorDataKit +import WebInspectorProxyKit import WebInspectorUIBase /// The UIKit-facing inspection session used by `WebInspectorViewController`. /// -/// A session owns attachment lifecycle, the current DataKit context, and +/// 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, @@ -30,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, @@ -49,7 +57,7 @@ public final class WebInspectorSession { } ) { self.interface = InterfaceModel(tabs: tabs) - self.dataContext = context + self.model = context self.makePageUserInterfaceStyleObserver = makePageUserInterfaceStyleObserver } @@ -57,18 +65,14 @@ public final class WebInspectorSession { stopPageUserInterfaceStyleObservation() } - 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) @@ -78,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) @@ -91,78 +95,85 @@ 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 } ) async throws { try await attach( - makeContainer: makeContainer, + makeProxy: makeProxy, makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver ) } private func attach( - makeContainer: @MainActor () async throws -> WebInspectorContainer, + makeProxy: @MainActor () async throws -> WebInspectorProxy, makePageUserInterfaceStyleObserver: @MainActor ( @escaping @MainActor (UIUserInterfaceStyle) -> Void ) -> (any WebInspectorPageUserInterfaceStyleObserving)? ) 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 + } + guard isCurrentAttachmentGeneration(generation) else { 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() + } + + /// Permanently closes this session and its model connection. + public func close() async { + advanceAttachmentGeneration() + stopPageUserInterfaceStyleObservation() + await model.close() } /// Disables transient page interaction without tearing down the connection. package func suspendBackendInteraction() 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 { + guard model.state == .attached else { return } - if context.isElementPickerEnabled { - try? await context.setElementPickerEnabled(false) + if (try? model.isElementPickerEnabled) == true { + try? await model.setElementPickerEnabled(false) } - try? await context.hideHighlight() + try? await model.hideDOMHighlight() } private func startPageUserInterfaceStyleObservation( @@ -202,27 +213,6 @@ public final class WebInspectorSession { attachmentGeneration == generation } - package func installDataContext(_ context: WebInspectorContext) { - dataContext = context - interface.contextDidChange() - } - - private func stopContainer(replaceContextWithDetached: Bool) async { - interface.contextDidChange() - if let container { - self.container = nil - await container.close() - } else { - await dataContext.stop() - } - if replaceContextWithDetached { - installDataContext(Self.makeDetachedDataContext()) - } - } - - private static func makeDetachedDataContext() -> WebInspectorContext { - WebInspectorContext.detached(isolation: MainActor.shared) - } } #if DEBUG 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/DOMTabController.swift b/Sources/WebInspectorUI/DOMTabController.swift index 9a482e74..170c7688 100644 --- a/Sources/WebInspectorUI/DOMTabController.swift +++ b/Sources/WebInspectorUI/DOMTabController.swift @@ -54,7 +54,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { session: session, contentStore: contentStore ), - context: session.context + context: session.model ) case (.compact, .domElement): DOMCompactNavigationController( @@ -76,7 +76,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { session: session, contentStore: contentStore ), - context: session.context + context: session.model ) ) } @@ -90,7 +90,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { for: contentKey(ContentID.tree), contextEpoch: session.interface.contextBoundContentRevision ) { - DOMTreeViewController(context: session.context) + DOMTreeViewController(context: session.model) } } @@ -102,7 +102,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { for: contentKey(ContentID.element), contextEpoch: session.interface.contextBoundContentRevision ) { - DOMElementViewController(context: session.context) + DOMElementViewController(context: session.model) } } diff --git a/Sources/WebInspectorUI/NetworkTabController.swift b/Sources/WebInspectorUI/NetworkTabController.swift index fcb8cf5a..a499dd88 100644 --- a/Sources/WebInspectorUI/NetworkTabController.swift +++ b/Sources/WebInspectorUI/NetworkTabController.swift @@ -25,7 +25,7 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { ) -> UIViewController { let contextEpoch = session.interface.contextBoundContentRevision return contentStore.networkViewController( - context: session.context, + context: session.model, contextEpoch: contextEpoch ) { [weak contentStore] model in guard let contentStore else { diff --git a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift index 16374115..2c08e7f0 100644 --- a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -32,7 +32,7 @@ package final class PresentationContentStore { } package typealias NetworkPanelModelFactory = @MainActor ( - _ context: WebInspectorContext + _ context: WebInspectorModelContext ) async throws -> NetworkPanelModel @ObservationIgnored private let contentCache = WebInspectorTab.ContentCache() @@ -40,7 +40,7 @@ package final class PresentationContentStore { @ObservationIgnored private var networkResourceTask: Task? @ObservationIgnored private var networkRetirementTask: Task? @ObservationIgnored private var networkResourceViewControllers: [WeakNetworkResourceViewController] = [] - @ObservationIgnored private var networkContext: WebInspectorContext? + @ObservationIgnored private var networkContext: WebInspectorModelContext? private var networkResourceState: NetworkResourceState = .idle package private(set) var contextEpoch: Int? package private(set) var networkResourceGeneration: UInt64 = 0 @@ -90,7 +90,7 @@ package final class PresentationContentStore { } package func networkViewController( - context: WebInspectorContext, + context: WebInspectorModelContext, contextEpoch: Int, makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController ) -> NetworkTabResourceViewController { @@ -155,7 +155,7 @@ package final class PresentationContentStore { } private func startNetworkResource( - context: WebInspectorContext, + context: WebInspectorModelContext, contextEpoch: Int ) { precondition( diff --git a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift index 6b080ec6..8ec96ad3 100644 --- a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift +++ b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift @@ -1,5 +1,6 @@ #if canImport(UIKit) import UIKit +import WebInspectorDataKit /// A tab shown by the built-in WebInspectorKit UI. /// @@ -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 { @@ -72,11 +76,13 @@ 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) } @@ -90,11 +96,13 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: ID, title: String, image: UIImage? = nil, + requiredDomains: Set = [], makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController ) { self.id = id self.title = title self.image = image + self.requiredDomains = requiredDomains self.content = .custom(CustomContent(makeViewController: makeViewController)) } @@ -103,12 +111,14 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: ID, title: String, systemImage: String, + requiredDomains: Set = [], makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController ) { self.init( id: id, title: title, image: UIImage(systemName: systemImage), + requiredDomains: requiredDomains, makeViewController: makeViewController ) } @@ -118,7 +128,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. @@ -126,7 +137,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/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.reloadPage() + try await context.reload() } catch { WebInspectorUIDOMLog.debug("DOM reload failed: \(String(describing: error))") } @@ -236,19 +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.removeDOMNodes( - [selectedNode.id], - options: .automatic - ) - 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 @@ -270,7 +269,7 @@ package final class DOMNavigationItems: NSObject { return } do { - try await context.setElementPickerEnabled(!context.isElementPickerEnabled) + try await context.setElementPickerEnabled(!(try context.isElementPickerEnabled)) } catch { WebInspectorUIDOMLog.debug("DOM picker toggle failed: \(String(describing: error))") } @@ -283,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/DOMElementViewController+Preview.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift index 3f8ac962..98e284d8 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift @@ -15,7 +15,7 @@ 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) context.seedSelectedNodeStyles(matchedStyles: previewMatchedStyles()) diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index 60b3bd3b..36d37665 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -7,7 +7,7 @@ import UIKit @MainActor package final class DOMElementViewController: UICollectionViewController { - private let context: WebInspectorContext + private let context: WebInspectorModelContext private var statusTask: Task? private var selectedStylesObservation: PortableObservationTracking.Token? private var observedSelectedNodeObjectID: ObjectIdentifier? @@ -31,7 +31,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()) } 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 28d78ed8..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 @@ -194,7 +194,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat #endif init( - context: WebInspectorContext, + context: WebInspectorModelContext, requestChildrenAction: RequestChildrenAction? = nil, highlightNodeAction: HighlightNodeAction? = nil, restoreHighlightAction: RestoreHighlightAction? = nil, diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift index db78c9ae..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.requestChildren(for: 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.highlightNode(for: 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.highlightNode(for: selectedNode.id) + if let selectedNode = try context.selectedDOMNode { + try await context.highlightDOMNode(selectedNode) } else { - try await context.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,38 +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.removeDOMNodes( - nodeIDs, - options: .automatic - ) - 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) { @@ -154,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/WebInspectorUINetwork/NetworkPanelModel.swift b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift index e2da205e..89a9c1f1 100644 --- a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift +++ b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift @@ -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 + } } } } @@ -33,7 +40,7 @@ package final class NetworkPanelModel { case retired } - package let context: WebInspectorContext + package let context: WebInspectorModelContext package let requests: WebInspectorFetchedResults private let collectionState: NetworkRequestCollectionState package private(set) var selectedRequestID: NetworkRequest.ID? @@ -49,7 +56,7 @@ package final class NetworkPanelModel { @ObservationIgnored private var lifecycle: Lifecycle private init( - context: WebInspectorContext, + context: WebInspectorModelContext, requests: WebInspectorFetchedResults, query: NetworkQuery ) { @@ -68,7 +75,7 @@ package final class NetworkPanelModel { } /// Creates a ready Network panel after its atomic initial query snapshot is available. - package static func make(context: WebInspectorContext) async throws -> NetworkPanelModel { + 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) @@ -109,7 +116,7 @@ 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?) { @@ -197,7 +204,7 @@ package final class NetworkPanelModel { package func fetchResponseBodyIfNeeded(for request: NetworkRequest) { requireActive() - responseBodyFetchCoordinator.fetchIfNeeded(for: request) + responseBodyFetchCoordinator.fetchIfNeeded(for: request, context: context) } /// Cancels and awaits the current query replacement before releasing this owner. diff --git a/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift b/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift index ec277844..2a145b64 100644 --- a/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift +++ b/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift @@ -44,13 +44,13 @@ package enum NetworkPreviewFixtures { } #endif - package static func makeContext(mode: Mode) -> WebInspectorContext { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + 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( @@ -161,7 +161,7 @@ package enum NetworkPreviewFixtures { @discardableResult private static func applyRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID: String, url: String, method: String = "GET", From ee9d920308145802af2f12026297527020ed8b8c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:10:41 +0900 Subject: [PATCH 52/81] refactor(proxy)!: remove physical target and cold event APIs --- .../StoryA2ProxyKitContractTests.swift | 36 +- Docs/MIGRATION.md | 29 + README.md | 3 +- .../RuntimeStateStore.swift | 11 +- .../WebInspectorModelContext.swift | 3 +- Sources/WebInspectorProxyKit/CSS.swift | 54 - Sources/WebInspectorProxyKit/Console.swift | 54 - Sources/WebInspectorProxyKit/DOM.swift | 30 - .../WebInspectorProxyKit/EventStreams.swift | 7 - .../LiveProxyEventDecoder.swift | 2 +- .../LiveWebInspectorProxyBackend.swift | 226 ---- Sources/WebInspectorProxyKit/Network.swift | 54 - Sources/WebInspectorProxyKit/Runtime.swift | 58 +- .../Transport/ConnectionEventProjection.swift | 2 +- .../TransportEventSubscriberRegistry.swift | 53 - .../Transport/TransportSession.swift | 61 - .../WebInspectorDomainHandle.swift | 40 - .../WebInspectorProxy.swift | 178 +-- .../WebInspectorProxyBackend.swift | 12 - .../WebInspectorProxyKit.md | 37 +- .../WebInspectorTarget.swift | 83 +- .../WebInspectorProxyKitTesting.md | 3 +- .../ConnectionModelFeedTests.swift | 35 +- .../TransportReceiverLifecycleTests.swift | 13 +- .../TransportSessionTests.swift | 259 ---- .../WebInspectorProxyKitBackendTests.swift | 125 -- ...torProxyTransportCommandBackendTests.swift | 1134 +---------------- .../WebInspectorTestPeerTests.swift | 2 +- 28 files changed, 143 insertions(+), 2461 deletions(-) delete mode 100644 Sources/WebInspectorProxyKit/EventStreams.swift delete mode 100644 Sources/WebInspectorProxyKit/Transport/TransportEventSubscriberRegistry.swift diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift index befb2bce..2f042831 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift @@ -29,21 +29,7 @@ func testJSONObjectPublicInitializersValidateAndCanonicalizeTypedFixtures() thro @Test func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - guard case .page = target.kind else { - Issue.record("Expected WebInspectorProxyTestRuntime to install a page target.") - return - } - #expect(await runtime.proxy.canReload) - - let page = runtime.proxy.page - let targetDOM: DOM = target.dom - let targetCSS: CSS = target.css - let targetNetwork: Network = target.network - let targetConsole: Console = target.console - let targetRuntime: Runtime = target.runtime - let targetPage: Page = target.page + let page = runtime.page let currentPageDOM: DOM = page.dom let currentPageCSS: CSS = page.css let currentPageNetwork: Network = page.network @@ -51,11 +37,6 @@ func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() let currentPageRuntime: Runtime = page.runtime let currentPageCommands: Page = page.page _ = ( - targetDOM, - targetCSS, - targetConsole, - targetRuntime, - targetPage, currentPageDOM, currentPageCSS, currentPageNetwork, @@ -64,28 +45,27 @@ func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() currentPageCommands ) - let enableTask = Task { - try await targetNetwork.enable() + let reloadTask = Task { + try await currentPageCommands.reload() } let command = try await runtime.peer.commands.next() #expect(command.destination == .target("page-main")) - #expect(command.method == "Network.enable") + #expect(command.method == "Page.reload") try await runtime.peer.reply(to: command) - try await enableTask.value + 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 eventTask = Task { - try await target.network.withEvents { firstEvents in - try await target.network.withEvents { secondEvents in + 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", diff --git a/Docs/MIGRATION.md b/Docs/MIGRATION.md index a57151d6..c69bbff4 100644 --- a/Docs/MIGRATION.md +++ b/Docs/MIGRATION.md @@ -8,6 +8,35 @@ when upgrading WebInspectorKit. Sections are grouped by release, newest first. 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. +### 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 diff --git a/README.md b/README.md index 8ef2384a..4936c149 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,9 @@ import WebInspectorProxyKit import WebInspectorProxyKitTesting let runtime = try await WebInspectorProxyTestRuntime.start() -let page = try await runtime.proxy.waitForCurrentPage() let reload = Task { - try await page.page.reload() + try await runtime.page.page.reload() } let command = try await runtime.peer.commands.next() diff --git a/Sources/WebInspectorDataKit/RuntimeStateStore.swift b/Sources/WebInspectorDataKit/RuntimeStateStore.swift index 9ec1c837..516cbdde 100644 --- a/Sources/WebInspectorDataKit/RuntimeStateStore.swift +++ b/Sources/WebInspectorDataKit/RuntimeStateStore.swift @@ -199,18 +199,19 @@ package final class RuntimeStateStore { package func apply( _ event: Runtime.Event, - sourceTargetID: WebInspectorTarget.ID? + sourceTargetID: WebInspectorTarget.ID?, + isCurrentPageTarget: Bool ) { switch event { case let .executionContextCreated(context): applyExecutionContextCreated(context) case let .executionContextDestroyed(id): applyExecutionContextDestroyed(id) - case let .executionContextsCleared(eventTargetID): - if eventTargetID == .currentPage || eventTargetID == sourceTargetID { + case .executionContextsCleared: + if isCurrentPageTarget { reset() - } else { - clear(targetID: eventTargetID) + } else if let sourceTargetID { + clear(targetID: sourceTargetID) } case .unknown: break diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 20553080..02960675 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -1686,7 +1686,8 @@ public final class WebInspectorModelContext { } runtimeState.apply( event, - sourceTargetID: target.id + sourceTargetID: target.id, + isCurrentPageTarget: target.id == binding.currentPageID ) return ReducerWork.none } diff --git a/Sources/WebInspectorProxyKit/CSS.swift b/Sources/WebInspectorProxyKit/CSS.swift index 41d5ce09..924bc2f6 100644 --- a/Sources/WebInspectorProxyKit/CSS.swift +++ b/Sources/WebInspectorProxyKit/CSS.swift @@ -18,22 +18,6 @@ public struct CSS: Sendable, WebInspectorEventDomainHandle { return value } - /// Enables CSS domain events and commands for the target. - public func enable() async throws { - try await dispatchVoid( - method: "enable", - payload: EnablePayload() - ) - } - - /// Disables CSS domain events for the target. - public func disable() async throws { - try await dispatchVoid( - method: "disable", - payload: DisablePayload() - ) - } - /// Runs an operation with an atomically registered CSS event scope. public func withEvents( buffering: WebInspectorEventBufferingPolicy = .bounded(256), @@ -111,21 +95,6 @@ public struct CSS: Sendable, WebInspectorEventDomainHandle { ) } - /// CSS domain events emitted by this target. - public var events: EventStream { - EventStream { - endpoint.cssEvents() - } - } - - package struct EnablePayload: Sendable { - package init() {} - } - - package struct DisablePayload: Sendable { - package init() {} - } - package struct GetMatchedStylesForNodePayload: Sendable { package let node: DOM.Node.ID @@ -648,29 +617,6 @@ public struct CSS: Sendable, WebInspectorEventDomainHandle { 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 5d4b6859..b6cbb379 100644 --- a/Sources/WebInspectorProxyKit/Console.swift +++ b/Sources/WebInspectorProxyKit/Console.swift @@ -18,22 +18,6 @@ public struct Console: Sendable, WebInspectorEventDomainHandle { return value.event } - /// Enables Console domain events and commands for the target. - public func enable() async throws { - try await dispatchVoid( - method: "enable", - payload: EnablePayload() - ) - } - - /// Disables Console domain events for the target. - public func disable() async throws { - try await dispatchVoid( - method: "disable", - payload: DisablePayload() - ) - } - /// Runs an operation with an atomically registered Console event scope. public func withEvents( buffering: WebInspectorEventBufferingPolicy = .bounded(256), @@ -65,21 +49,6 @@ public struct Console: Sendable, WebInspectorEventDomainHandle { ) } - /// Console domain events emitted by this target. - public var events: EventStream { - EventStream { - endpoint.consoleEvents() - } - } - - package struct EnablePayload: Sendable { - package init() {} - } - - package struct DisablePayload: Sendable { - package init() {} - } - package struct ClearMessagesPayload: Sendable { package init() {} } @@ -287,27 +256,4 @@ public struct Console: Sendable, WebInspectorEventDomainHandle { } } - /// 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 7a6d440e..5093eaba 100644 --- a/Sources/WebInspectorProxyKit/DOM.swift +++ b/Sources/WebInspectorProxyKit/DOM.swift @@ -265,13 +265,6 @@ public struct DOM: Sendable, WebInspectorEventDomainHandle { ) } - /// DOM domain events emitted by this target. - public var events: EventStream { - EventStream { - endpoint.domEvents() - } - } - package struct GetDocumentPayload: Sendable { package init() {} } @@ -619,29 +612,6 @@ public struct DOM: Sendable, WebInspectorEventDomainHandle { 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/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/LiveProxyEventDecoder.swift b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift index bea4aeb6..2de509c8 100644 --- a/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift +++ b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift @@ -282,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)) } diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index e2133b78..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( @@ -28,28 +26,6 @@ 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( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain, - minimumCount: Int - ) async { - await eventSubscriptions.waitForActiveSubscribers( - LiveProxyEventSubscriptionKey(route: route, targetID: targetID, domain: domain), - minimumCount: minimumCount - ) - } - package func acquireEventScope( route: RoutingTargetID, targetID: WebInspectorTarget.ID, @@ -70,52 +46,6 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { try await transport.releaseEventScope(id) } - 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) - } - } - } - } - private func mapTransportError(_ error: any Error, domain: String, method: String) -> any Error { guard let transportError = error as? TransportSession.Error else { return error @@ -157,162 +87,6 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return rawValue } - private nonisolated func shouldDeliver(_ event: ProtocolEvent, to route: RoutingTargetID) async -> Bool { - let snapshot = await transport.snapshot() - return ConnectionEventProjection.shouldDeliver(event, to: route, in: snapshot) - } - - private nonisolated func projectedEvent( - _ proxyEvent: WebInspectorProxyEvent, - from event: ProtocolEvent, - route: RoutingTargetID - ) async -> WebInspectorProxyEvent { - let snapshot = await transport.snapshot() - return ConnectionEventProjection.projectedEvent(proxyEvent, from: event, route: route, in: snapshot) - } - - private nonisolated func lifecycleTarget( - for event: ProtocolEvent, - route: RoutingTargetID, - targetID: WebInspectorTarget.ID - ) async -> WebInspectorLifecycleTarget? { - let snapshot = await transport.snapshot() - return ConnectionEventProjection.lifecycleTarget( - for: event, - route: route, - targetID: targetID, - in: snapshot - ) - } - -} - -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 { diff --git a/Sources/WebInspectorProxyKit/Network.swift b/Sources/WebInspectorProxyKit/Network.swift index b3f5e50e..2e89266a 100644 --- a/Sources/WebInspectorProxyKit/Network.swift +++ b/Sources/WebInspectorProxyKit/Network.swift @@ -18,22 +18,6 @@ public struct Network: Sendable, WebInspectorEventDomainHandle { return value } - /// Enables Network domain events and commands for the target. - public func enable() async throws { - try await dispatchVoid( - method: "enable", - payload: EnablePayload() - ) - } - - /// Disables Network domain events for the target. - public func disable() async throws { - try await dispatchVoid( - method: "disable", - payload: DisablePayload() - ) - } - /// Runs an operation with an atomically registered Network event scope. /// /// The first scope registers before `Network.enable` is sent. Scope @@ -64,21 +48,6 @@ public struct Network: Sendable, WebInspectorEventDomainHandle { ) } - /// Network domain events emitted by this target. - public var events: EventStream { - EventStream { - endpoint.networkEvents() - } - } - - package struct EnablePayload: Sendable { - package init() {} - } - - package struct DisablePayload: Sendable { - package init() {} - } - package struct GetResponseBodyPayload: Sendable { package let id: Request.ID package let backendResourceIdentifier: BackendResourceID? @@ -436,29 +405,6 @@ public struct Network: Sendable, WebInspectorEventDomainHandle { } } - /// 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/Runtime.swift b/Sources/WebInspectorProxyKit/Runtime.swift index 5d82c84c..a574f264 100644 --- a/Sources/WebInspectorProxyKit/Runtime.swift +++ b/Sources/WebInspectorProxyKit/Runtime.swift @@ -18,22 +18,6 @@ public struct Runtime: Sendable, WebInspectorEventDomainHandle { return value } - /// Enables Runtime domain events and commands for the target. - public func enable() async throws { - try await dispatchVoid( - method: "enable", - payload: EnablePayload() - ) - } - - /// Disables Runtime domain events for the target. - public func disable() async throws { - try await dispatchVoid( - method: "disable", - payload: DisablePayload() - ) - } - /// Runs an operation with an atomically registered Runtime event scope. public func withEvents( buffering: WebInspectorEventBufferingPolicy = .bounded(256), @@ -112,21 +96,6 @@ public struct Runtime: Sendable, WebInspectorEventDomainHandle { ) } - /// Runtime domain events emitted by this target. - public var events: EventStream { - EventStream { - endpoint.runtimeEvents() - } - } - - package struct EnablePayload: Sendable { - package init() {} - } - - package struct DisablePayload: Sendable { - package init() {} - } - package struct EvaluatePayload: Sendable { package let expression: String package let context: ExecutionContext.ID? @@ -557,36 +526,13 @@ public struct Runtime: Sendable, WebInspectorEventDomainHandle { /// 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/Transport/ConnectionEventProjection.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift index 0b2cf5be..0a11f2df 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift @@ -338,7 +338,7 @@ package enum ConnectionEventProjection { case let .executionContextDestroyed(id): return .executionContextDestroyed(scopedExecutionContextID(id, targetRawValue: targetScopeRawValue)) case .executionContextsCleared: - return .executionContextsCleared(target: WebInspectorTarget.ID(targetScopeRawValue)) + return .executionContextsCleared case let .unknown(rawEvent): return .unknown(rawEvent) } 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/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index d0a01992..8defe264 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -413,7 +413,6 @@ package actor ConnectionCore { 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 @@ -479,7 +478,6 @@ package actor ConnectionCore { provisionalTargetMessageStore = TransportProvisionalTargetMessageStore() styleSheetRouting = TransportStyleSheetRouting() runtimeContextRegistry = RuntimeContextRegistry() - eventSubscribers = TransportEventSubscriberRegistry() eventScopes = ConnectionEventScopeRegistry() modelFeed = nil replayWasTaintedByDirectConsumer = false @@ -515,7 +513,6 @@ package actor ConnectionCore { // 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. - eventSubscribers.finishAndRemoveAll() eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) modelFeed?.mailbox.finish(throwing: WebInspectorProxyError.closed) modelFeed = nil @@ -581,36 +578,6 @@ package actor ConnectionCore { return cause } - package func events(for domain: ProtocolDomain) -> AsyncStream { - guard isOpen else { - return finishedStream(of: ProtocolEvent.self) - } - claimLegacyPassiveConsumer() - 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) - } - } - return pair.stream - } - - package func orderedEvents() -> AsyncStream { - guard isOpen else { - return finishedStream(of: ProtocolEvent.self) - } - claimLegacyPassiveConsumer() - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - let subscriberID = eventSubscribers.insertOrdered(pair.continuation) - pair.continuation.onTermination = { [weak self] _ in - Task { - await self?.removeOrderedSubscriber(subscriberID) - } - } - return pair.stream - } - package func openModelFeed( configuredDomains: Set, capacity: Int @@ -1946,18 +1913,6 @@ package actor ConnectionCore { replayWasTaintedByDirectConsumer = true } - private func claimLegacyPassiveConsumer() { - do { - try claimDirectConsumer() - } catch WebInspectorProxyError.connectionInUse { - preconditionFailure( - "Legacy passive event subscriptions cannot start while the connection is exclusively claimed by a model feed. This migration-only event surface must not be adapted around the feed claim." - ) - } catch { - preconditionFailure("Unexpected direct-consumer claim failure: \(error)") - } - } - private func enqueueModelFeedRecord( _ record: ConnectionModelFeedRecord ) -> Bool { @@ -5020,12 +4975,6 @@ package actor ConnectionCore { guard isOpen else { return EventEmissionEffects(commandInvalidation: commandInvalidation) } - for continuation in eventSubscribers.continuations(for: domain) { - continuation.yield(envelope) - } - for continuation in eventSubscribers.orderedContinuations { - continuation.yield(envelope) - } return EventEmissionEffects( mainPageTargetNotification: prepareMainPageTargetNotificationIfNeeded( receivedSequence: eventSequence.sequence @@ -5245,14 +5194,6 @@ package actor ConnectionCore { waiter?.fulfill(.failure(error)) } - private func removeSubscriber(_ subscriberID: UInt64, domain: ProtocolDomain) { - eventSubscribers.remove(subscriberID, domain: domain) - } - - private func removeOrderedSubscriber(_ subscriberID: UInt64) { - eventSubscribers.removeOrdered(subscriberID) - } - private func removePendingReply(_ key: TransportSession.PendingKey) { replyStore.removePendingReply(key) } @@ -5433,7 +5374,6 @@ package actor ConnectionCore { provisionalTargetMessageStore.removeAll() inboundMessageQueue = TransportInboundMessageQueue() if cause != .explicitClose { - eventSubscribers.finishAndRemoveAll() } return TerminalOperation( @@ -5462,7 +5402,6 @@ package actor ConnectionCore { state = .closed if cause == .explicitClose { eventScopes.finishSubscribers(with: nil) - eventSubscribers.finishAndRemoveAll() modelFeed?.mailbox.finish() } modelFeed = nil diff --git a/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift index 2865d6fc..3a29a8c9 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift @@ -82,46 +82,6 @@ package struct DomainEndpoint: Sendable { ) } - package func domEvents() -> AsyncStream { - guard let proxy = proxyReference.resolve() else { - return finishedStream() - } - return proxy.domEvents(targetID: targetID, route: route) - } - - package func cssEvents() -> AsyncStream { - guard let proxy = proxyReference.resolve() else { - return finishedStream() - } - return proxy.cssEvents(targetID: targetID, route: route) - } - - package func networkEvents() -> AsyncStream { - guard let proxy = proxyReference.resolve() else { - return finishedStream() - } - return proxy.networkEvents(targetID: targetID, route: route) - } - - package func consoleEvents() -> AsyncStream { - guard let proxy = proxyReference.resolve() else { - return finishedStream() - } - return proxy.consoleEvents(targetID: targetID, route: route) - } - - package func runtimeEvents() -> AsyncStream { - guard let proxy = proxyReference.resolve() else { - return finishedStream() - } - return proxy.runtimeEvents(targetID: targetID, route: route) - } -} - -private func finishedStream() -> AsyncStream { - AsyncStream { continuation in - continuation.finish() - } } /// Package-owned contract shared by the closed set of Web Inspector domain diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index e3a78419..ec4ea302 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -22,16 +22,13 @@ private struct ProtocolCommandTarget: Sendable { /// /// `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 -/// ``WebInspectorTarget`` values. +/// 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 ?? "") /// @@ -72,8 +69,7 @@ public actor WebInspectorProxy { /// 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, @@ -138,8 +134,7 @@ public actor WebInspectorProxy { } } - /// The currently known page target, if bootstrap has completed. - public var currentPage: WebInspectorTarget? { + package var currentPage: WebInspectorTarget? { get async { guard let record = await core.currentMainPageRecord() else { return nil @@ -154,20 +149,7 @@ public actor WebInspectorProxy { } } - /// A Boolean value indicating whether the proxy has an open page target - /// that can receive reload commands. - public var canReload: Bool { - get async { - await core.currentMainPageRecord() != nil - } - } - - /// 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 { + package func waitForCurrentPage() async throws -> WebInspectorTarget { try await ensureOpenForCurrentPageAccess() do { return try await currentPageTarget( @@ -200,18 +182,6 @@ public actor WebInspectorProxy { configuration.bootstrapTimeout } - /// Reloads the currently inspected page without ignoring cache. - public func reload() async throws { - let pageTarget = try await waitForCurrentPage() - 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 @@ -281,144 +251,6 @@ public actor WebInspectorProxy { return try await backend.dispatchCommand(command) } - package nonisolated func domEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .dom) { event in - guard case let .dom(value) = event else { - return nil - } - return value - } - } - - 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.") - } - 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 func resolvedCommandTarget( targetID: WebInspectorTarget.ID, route: RoutingTargetID, diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift index 6dc8cfef..4785b1ba 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift @@ -61,18 +61,6 @@ package protocol WebInspectorProxyBackend: Sendable { _ command: WebInspectorProxyCommand ) async throws -> Result - nonisolated func events( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) -> AsyncStream - - func waitForEventSubscription( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) async - func acquireEventScope( route: RoutingTargetID, targetID: WebInspectorTarget.ID, diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md b/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md index 4a93c06c..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 handles 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 566d17a5..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 handles 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,33 +50,17 @@ public struct WebInspectorTarget: Identifiable, Sendable { package static let currentPage = ID("current-page") } - /// The kind of backend target represented by a ``WebInspectorTarget``. - public enum Kind: Equatable, 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 handles. - public let id: ID - - /// The backend target kind. - public let kind: Kind - - /// 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 id: ID + package let kind: Kind + package let frameID: FrameID? + package let isProvisional: Bool package let proxyReference: WebInspectorProxyReference package let route: RoutingTargetID @@ -147,33 +127,27 @@ public struct WebInspectorTarget: Identifiable, Sendable { ) } - /// A target-scoped handle for DOM protocol commands and events. - public var dom: DOM { + package var dom: DOM { DOM(endpoint: endpoint) } - /// A target-scoped handle for CSS protocol commands and events. - public var css: CSS { + package var css: CSS { CSS(endpoint: endpoint) } - /// A target-scoped handle for Network protocol commands and events. - public var network: Network { + package var network: Network { Network(endpoint: endpoint) } - /// A target-scoped handle for Console protocol commands and events. - public var console: Console { + package var console: Console { Console(endpoint: endpoint) } - /// A target-scoped handle for Runtime protocol commands and events. - public var runtime: Runtime { + package var runtime: Runtime { Runtime(endpoint: endpoint) } - /// A target-scoped handle for Page protocol commands. - public var page: Page { + package var page: Page { Page(endpoint: endpoint) } @@ -181,33 +155,6 @@ public struct WebInspectorTarget: Identifiable, Sendable { Inspector(endpoint: endpoint) } - package var lifecycleEvents: AsyncStream { - guard let proxy = proxyReference.resolve() else { - return AsyncStream { continuation in - continuation.finish() - } - } - return proxy.targetLifecycleEvents(targetID: id, route: route) - } - - package var targetedConsoleEvents: AsyncStream { - guard let proxy = proxyReference.resolve() else { - return AsyncStream { continuation in - continuation.finish() - } - } - return proxy.targetedConsoleEvents(targetID: id, route: route) - } - - package func waitForModelEventSubscriptions() async { - guard let proxy = proxyReference.resolve() else { - return - } - 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, diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md index 0937085e..08aaf784 100644 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md @@ -17,10 +17,9 @@ the resulting raw command: import WebInspectorProxyKitTesting let runtime = try await WebInspectorProxyTestRuntime.start() -let page = try await runtime.proxy.waitForCurrentPage() let reload = Task { - try await page.page.reload() + try await runtime.page.page.reload() } let command = try await runtime.peer.commands.next() diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index a7b2d246..9c52030a 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -560,7 +560,16 @@ func domDocumentInvalidationOverflowIsFirstTerminalAndDoesNotGuessResync() async @Test func modelFeedAndDirectConsumersClaimConnectionExclusivelyInBothOrders() async throws { let directFirstCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) - _ = await directFirstCore.events(for: .network) + _ = 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: [], capacity: 8) } @@ -603,17 +612,6 @@ func modelFeedAndDirectConsumersClaimConnectionExclusivelyInBothOrders() async t await feedFirstCore.close() } -#if os(macOS) -@Test -func legacyPassiveStreamAfterModelFeedIsAProgrammerError() async { - await #expect(processExitsWith: .failure) { - let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) - _ = try await core.openModelFeed(configuredDomains: [], capacity: 8) - _ = await core.events(for: .network) - } -} -#endif - @Test func explicitConnectionCloseFinishesModelFeedOnlyAfterCloseQuiescence() async throws { let closeGate = ModelFeedAsyncGate() @@ -815,13 +813,6 @@ func replacementMainPageWaiterResumesAfterCapabilityOwnershipIsReconciled() asyn _ = await core.receiveRootMessage( #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# ) - let orderedEvents = await core.orderedEvents() - let eventObservationTask = Task { - var iterator = orderedEvents.makeAsyncIterator() - let event = await iterator.next() - let purposes = await core.pendingReplyPurposes() - return (event, purposes) - } let waiterTask = Task { try await core.waitForCurrentMainPageTarget(timeout: .seconds(1)) } @@ -833,13 +824,9 @@ func replacementMainPageWaiterResumesAfterCapabilityOwnershipIsReconciled() asyn frameID: "main-frame" )) let replacement = try await waiterTask.value - let eventObservation = await eventObservationTask.value - let replacementEvent = try #require(eventObservation.0) - let pendingPurposes = eventObservation.1 + let pendingPurposes = await core.pendingReplyPurposes() #expect(replacement.targetID == ProtocolTarget.ID("page-new")) - #expect(replacementEvent.method == "Target.targetCreated") - #expect(replacementEvent.targetID == ProtocolTarget.ID("page-new")) #expect(pendingPurposes.count == 1) let pending = try #require(pendingPurposes.first) guard case let .target(replyKey) = pending.key, diff --git a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift index 1815328c..45dceaa9 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift @@ -308,22 +308,14 @@ func terminalTaskDoesNotKeepCoreAliveAcrossExternalCloseWait() async { } @Test -func explicitCloseKeepsLegacyAndStructuredStreamsOpenUntilReceiverQuiesces() async throws { +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 legacyStream = await graph.core.events(for: .network) - let legacyCompletion = ReceiverCompletionProbe() let structuredReady = ReceiverAsyncGate() let structuredCompletion = ReceiverCompletionProbe() - let legacyTask = Task { - var iterator = legacyStream.makeAsyncIterator() - let event = await iterator.next() - await legacyCompletion.finish() - return event - } let structuredTask = Task { do { return try await proxy.page.dom.withEvents { events in @@ -349,16 +341,13 @@ func explicitCloseKeepsLegacyAndStructuredStreamsOpenUntilReceiverQuiesces() asy } await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) - #expect(await legacyCompletion.isFinished == false) #expect(await structuredCompletion.isFinished == false) #expect(await graph.core.activeEventScopeSubscriberCountForTesting() == 1) await parser.release() await closeTask.value - #expect(await legacyTask.value == nil) #expect(await structuredTask.value) - #expect(await legacyCompletion.isFinished) #expect(await structuredCompletion.isFinished) } diff --git a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift index e59d9cc0..e89976ef 100644 --- a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift @@ -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() @@ -805,36 +765,6 @@ func bufferedProvisionalTargetReplySurvivesResponseTimeoutBeforeCommit() async t #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) } -@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 oldBindingPendingReplyFailsAsStaleAtCommitBeforeTimeout() async throws { let backend = FakeTransportBackend() @@ -880,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":{"source":"javascript","level":"log","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() @@ -1082,122 +958,6 @@ func runtimeExecutionContextRegistryAppliesTeardownEvents() async throws { #expect(await session.snapshot().executionContextsByKey[contextKey("page-main", 10)] == nil) } -@Test -func domainStreamsReceiveIndependentTargetEventsInOrder() 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":{"source":"javascript","level":"log","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") -} - -@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) - - 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","origin":"author"}}}"#) - 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, - ]) -} - -@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) - - 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","origin":"author"}}}"#) - 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"), - ]) -} - -@Test -func rootCSSStyleSheetAddedBeforeProvisionalFrameTargetReplaysAfterCommit() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let cssStream = await session.events(for: .css) - let cssEvents = ProtocolEventRecorder(stream: cssStream) - - 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","origin":"author"}}}"#) - 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"}}"#) - - 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"), - ]) -} - -@Test -func orderedStreamReceivesTargetEventsAcrossDomainsInTransportOrder() async throws { - 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]) -} - @Test func nativeFatalCallbackOwnsTerminalCauseAndFailsPendingWork() async throws { let backend = FakeTransportBackend() @@ -1230,25 +990,6 @@ func nativeFatalCallbackOwnsTerminalCauseAndFailsPendingWork() async throws { #expect(await core.snapshot().pendingRootReplyIDs.isEmpty) } -@Test -func connectionCoreCloseIsIdempotentAndFinishesStreamsOnce() async throws { - let backend = CountingTransportBackend() - let core = ConnectionCore(backend: backend) - let stream = await core.events(for: .network) - let nextEventTask = Task { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - - async let firstClose: Void = core.close() - async let secondClose: Void = core.close() - _ = await (firstClose, secondClose) - - #expect(await backend.detachCount == 1) - #expect(await nextEventTask.value == nil) - try await core.waitUntilClosed() -} - @Test func receiverDoesNotKeepExplicitlyClosedConnectionCoreAlive() async { let backend = FakeTransportBackend() diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift index 10bbc108..8aa36056 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift @@ -56,131 +56,6 @@ func rawPeerRoutesFrameRequestNodeThroughCurrentPageDOMAgent() async throws { await runtime.close() } -@Test -func rawInspectorEventResolvesNodeThroughProductionCommandPath() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() - } - await runtime.proxy.waitForEventSubscription( - targetID: target.id, - route: target.route, - domain: .dom - ) - await runtime.proxy.waitForEventSubscription( - targetID: target.id, - route: target.route, - domain: .inspector - ) - - try await runtime.peer.emitTargetEvent( - targetID: "page-main", - method: "Inspector.inspect", - parameters: try jsonObject( - #"{"object":{"type":"object","subtype":"node","objectId":"remote-node"},"hints":{}}"# - ) - ) - - let 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 event = try #require(await eventTask.value) - guard case let .inspect(nodeID) = event else { - Issue.record("Expected Inspector.inspect to resolve to DOM.inspect.") - await runtime.close() - return - } - #expect(nodeID == DOM.Node.ID("selected-node")) - await runtime.close() -} - -@Test -func rawFrameNetworkEventIsScopedOnSemanticCurrentPageRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let page = try await runtime.proxy.waitForCurrentPage() - try await runtime.peer.createTarget(.init( - id: "frame-target", - type: "frame", - frameID: "child-frame", - parentFrameID: "main-frame" - )) - let eventTask = Task { - var iterator = page.network.events.makeAsyncIterator() - return await iterator.next() - } - await runtime.proxy.waitForEventSubscription( - targetID: page.id, - route: page.route, - domain: .network - ) - try await runtime.peer.emitTargetEvent( - targetID: "frame-target", - method: "Network.responseReceived", - parameters: try responseReceivedParameters( - requestID: "frame-request", - status: 201, - resourceType: "XHR", - timestamp: 2 - ) - ) - - guard case let .responseReceived(frameID, _, _, _) = try #require(await eventTask.value) else { - Issue.record("Expected Network.responseReceived events.") - await runtime.close() - return - } - #expect(frameID == Network.Request.ID( - "frame-request", - scopedToTargetRawValue: "frame-target" - )) - await runtime.close() -} - -@Test(arguments: ["Network", "Console", "Runtime", "CSS"]) -func rawPeerDispatchesDomainEnableAndDisable(_ domain: String) async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - let enableTask = Task { - switch domain { - case "Network": try await target.network.enable() - case "Console": try await target.console.enable() - case "Runtime": try await target.runtime.enable() - case "CSS": try await target.css.enable() - default: preconditionFailure("Unexpected test domain.") - } - } - let enable = try await runtime.peer.commands.next() - #expect(enable.destination == .target("page-main")) - #expect(enable.method == "\(domain).enable") - try await runtime.peer.reply(to: enable) - try await enableTask.value - - let disableTask = Task { - switch domain { - case "Network": try await target.network.disable() - case "Console": try await target.console.disable() - case "Runtime": try await target.runtime.disable() - case "CSS": try await target.css.disable() - default: preconditionFailure("Unexpected test domain.") - } - } - let disable = try await runtime.peer.commands.next() - #expect(disable.destination == .target("page-main")) - #expect(disable.method == "\(domain).disable") - try await runtime.peer.reply(to: disable) - try await disableTask.value - await runtime.close() -} - @Test func rawPeerPreservesScopedCSSRoutingAndStripsWireIdentifierScope() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index e11e3277..ae69ab8d 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -553,26 +553,6 @@ func transportBackedProxyMaterializesCurrentPageFromTransportRegistry() async th #expect(target.route == .currentPage) } -@Test -func transportBackedProxyCloseDetachesTransportAndFinishesEventStreams() 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() - } - - await waitForEventSubscription(target, domain: .dom) - await proxy.close() - - #expect(await backend.isDetached()) - #expect(try await value(of: eventTask) == nil) -} - @Test func transportBackedProxyWaitUntilClosedSuspendsUntilClose() async throws { let backend = FakeTransportBackend() @@ -655,22 +635,13 @@ func droppingOpenProxyHandleReachesConnectionCoreDeinit() { func pageTargetAndDomainHandlesDoNotOwnTheProxyLifecycle() async { weak var weakProxy: WebInspectorProxy? var page: WebInspectorPage? - var target: WebInspectorTarget? var dom: DOM? do { let proxy = WebInspectorProxy(localStateOnly: ()) weakProxy = proxy page = proxy.page - target = WebInspectorTarget( - id: WebInspectorTarget.ID("page-main"), - kind: .page, - frameID: FrameID("main-frame"), - isProvisional: false, - proxy: proxy, - route: .currentPage - ) - dom = target?.dom + dom = page?.dom } #expect(weakProxy == nil) @@ -683,10 +654,6 @@ func pageTargetAndDomainHandlesDoNotOwnTheProxyLifecycle() async { await #expect(throws: WebInspectorProxyError.closed) { try await dom?.withEvents { _ in } } - var iterator = dom?.events.makeAsyncIterator() - #expect(await iterator?.next() == nil) - var lifecycleIterator = target?.lifecycleEvents.makeAsyncIterator() - #expect(await lifecycleIterator?.next() == nil) } @Test @@ -856,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() @@ -971,186 +849,6 @@ func transportBackedWaitForCurrentPageRefreshesDestroyedTargetWithoutLifecycleSu #expect(await proxy.currentPage?.frameID == FrameID("new-frame")) } -@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 proxy = try await WebInspectorProxy(transport: transport) - let target = pageTarget(proxy: proxy) - - 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 proxy = try await WebInspectorProxy(transport: transport) - let target = pageTarget(proxy: proxy) - - 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 proxy = try await WebInspectorProxy(transport: transport) - let target = pageTarget(proxy: proxy) - - 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 transportBackendProjectsFrameDOMEventsAndRoutesScopedCommands() async throws { let backend = FakeTransportBackend() @@ -1162,27 +860,6 @@ func transportBackendProjectsFrameDOMEventsAndRoutesScopedCommands() async throw 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() - } - - await waitForEventSubscription(target, domain: .dom) - 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 event = try await value(of: eventTask) - guard case let .setChildNodes(parentID, nodes)? = event 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) @@ -1363,394 +1040,46 @@ func transportBackendProjectsFrameDOMEventsAndRoutesScopedCommands() async throw transport, targetID: frameSetRuleSelector.targetIdentifier, messageID: try messageID(frameSetRuleSelector.message), - result: """ - { - "rule": { - "ruleId": {"styleSheetId": "frame-sheet", "ordinal": 7}, - "selectorList": {"selectors": [{"text": ".frame-card"}], "text": ".frame-card"}, - "origin": "author", - "style": { - "styleId": {"styleSheetId": "frame-sheet", "ordinal": 7}, - "cssProperties": [{"name": "color", "value": "blue"}], - "cssText": "color: blue;" - } - } - } - """ - ) - let updatedFrameRule = try await frameSetRuleSelectorTask.value - #expect(updatedFrameRule.id?.targetScopeRawValue == "frame-target") - #expect(updatedFrameRule.selectorList.text == ".frame-card") - - let frameSetGroupingHeaderTask = Task { - try await target.css.setGroupingHeaderText(frameRuleID, text: "@media (min-width: 600px)") - } - let frameSetGroupingHeader = try await waitForTargetMessage( - backend, - method: "CSS.setGroupingHeaderText" - ) - #expect(frameSetGroupingHeader.targetIdentifier == ProtocolTarget.ID("frame-target")) - parameters = try messageParameters(frameSetGroupingHeader.message) - let frameGroupingRulePayload = try #require(parameters["ruleId"] as? [String: Any]) - #expect(frameGroupingRulePayload["styleSheetId"] as? String == "frame-sheet") - #expect((frameGroupingRulePayload["ordinal"] as? NSNumber)?.intValue == 7) - #expect(parameters["headerText"] as? String == "@media (min-width: 600px)") - await receiveTargetReply( - transport, - targetID: frameSetGroupingHeader.targetIdentifier, - messageID: try messageID(frameSetGroupingHeader.message), - result: #"{"grouping":{"text":"@media (min-width: 600px)"}}"# - ) - #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 transportBackendIgnoresImpossibleFrameInspectorOrigin() 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() - - 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("frame-target"), - method: "Inspector.inspect", - params: #"{"object":{"objectId":"remote-frame-node","type":"object","subtype":"node"},"hints":{}}"# - ) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Inspector.inspect", - params: #"{"object":{"objectId":"remote-main-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-main-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 main-page Inspector.inspect to normalize to DOM.inspect.") - return - } - #expect(nodeID == DOM.Node.ID("42")) -} - -@Test -func transportBackendDecodesNetworkResponseEventForTargetRoute() 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 = pageTarget(proxy: proxy) - - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( - 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}"# - ) - - 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) -} - -@Test -func transportBackendDecodesNetworkResponseEventWithoutType() 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 = pageTarget(proxy: proxy) - - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( - 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}"# - ) - - 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) -} - -@Test -func transportBackendDeliversFrameNetworkEventsToCurrentPageRoute() 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 eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( - 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"}"# - ) - - 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 - } - #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 bodyTask = Task { - try await target.network.responseBody(for: id) - } - 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") - await receiveTargetReply( - transport, - targetID: bodyCommand.targetIdentifier, - messageID: try messageID(bodyCommand.message), - result: #"{"body":"frame body","base64Encoded":false}"# - ) - let body = try await bodyTask.value - #expect(body.data == "frame body") - #expect(body.base64Encoded == false) -} - -@Test -func transportBackendDropsNonPageDestroyAfterPageDestroyed() 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}}}"# - ) - 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) - } - } - defer { consumeTask.cancel() } - await waitForEventSubscription(target, domain: .target) - - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-main"}}"# - ) - // 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"}}"# - ) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-replacement")) - - let clock = ContinuousClock() - let deadline = clock.now + .seconds(2) - while clock.now < deadline { - if await recorder.snapshot().count >= 2 { - break - } - await Task.yield() - } - - 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.") - } -} - -private actor LifecycleEventRecorder { - private var events: [WebInspectorTargetLifecycleEvent] = [] - - func record(_ event: WebInspectorTargetLifecycleEvent) { - events.append(event) - } - - func snapshot() -> [WebInspectorTargetLifecycleEvent] { - events - } -} - -@Test -func transportBackendEventSubscriptionWaitCompletesOnCancellation() 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 waitTask = Task { - await proxy.waitForEventSubscription( - targetID: target.id, - route: target.route, - domain: .network - ) - return Task.isCancelled - } - for _ in 0..<10 { - await Task.yield() - } - waitTask.cancel() - - let wasCancelled = try await value(of: waitTask, timeout: .seconds(2)) - #expect(wasCancelled) -} - -@Test -func transportBackendDecodesMemoryCacheResourceType() 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 waitForEventSubscription(target, domain: .network) - 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":{}}}}"# + result: """ + { + "rule": { + "ruleId": {"styleSheetId": "frame-sheet", "ordinal": 7}, + "selectorList": {"selectors": [{"text": ".frame-card"}], "text": ".frame-card"}, + "origin": "author", + "style": { + "styleId": {"styleSheetId": "frame-sheet", "ordinal": 7}, + "cssProperties": [{"name": "color", "value": "blue"}], + "cssText": "color: blue;" + } + } + } + """ ) + let updatedFrameRule = try await frameSetRuleSelectorTask.value + #expect(updatedFrameRule.id?.targetScopeRawValue == "frame-target") + #expect(updatedFrameRule.selectorList.text == ".frame-card") - 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.") - return + let frameSetGroupingHeaderTask = Task { + try await target.css.setGroupingHeaderText(frameRuleID, text: "@media (min-width: 600px)") } - #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) -} - -@Test -func transportBackendKeepsBackendResourceIdentifierOnScopedFrameRequests() 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 frameSetGroupingHeader = try await waitForTargetMessage( + backend, + method: "CSS.setGroupingHeaderText" ) - 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 waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( + #expect(frameSetGroupingHeader.targetIdentifier == ProtocolTarget.ID("frame-target")) + parameters = try messageParameters(frameSetGroupingHeader.message) + let frameGroupingRulePayload = try #require(parameters["ruleId"] as? [String: Any]) + #expect(frameGroupingRulePayload["styleSheetId"] as? String == "frame-sheet") + #expect((frameGroupingRulePayload["ordinal"] as? NSNumber)?.intValue == 7) + #expect(parameters["headerText"] as? String == "@media (min-width: 600px)") + await receiveTargetReply( 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"}}"# + targetID: frameSetGroupingHeader.targetIdentifier, + messageID: try messageID(frameSetGroupingHeader.message), + result: #"{"grouping":{"text":"@media (min-width: 600px)"}}"# ) + #expect(try await frameSetGroupingHeaderTask.value.text == "@media (min-width: 600px)") - let event = try #require(try await value(of: eventTask)) - guard case let .requestWillBeSent(id, request, _, _, _) = event else { - Issue.record("Expected projected frame Network.requestWillBeSent.") - return - } - #expect(id.targetScopeRawValue == "frame-target") - #expect(request.backendResourceIdentifier == Network.BackendResourceID( - sourceProcessID: "9", - resourceID: "42" - )) } @Test @@ -1790,34 +1119,16 @@ func transportBackendForwardsBackendResourceIdentifierToResponseBodyCommand() as 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 waitForEventSubscription(target, domain: .network) - 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"}}"# - ) - - 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.") - return - } - #expect(request.backendResourceIdentifier == Network.BackendResourceID( + let id = Network.Request.ID("cached-request") + let backendResourceIdentifier = Network.BackendResourceID( sourceProcessID: "77", resourceID: "1234" - )) + ) let bodyTask = Task { try await target.network.responseBody( for: id, - backendResourceIdentifier: request.backendResourceIdentifier + backendResourceIdentifier: backendResourceIdentifier ) } let bodyCommand = try await waitForTargetMessage(backend, method: "Network.getResponseBody") @@ -1836,325 +1147,6 @@ func transportBackendForwardsBackendResourceIdentifierToResponseBodyCommand() as #expect(body.data == "cached body") } -@Test -func transportBackendDeliversParentlessFrameNetworkEventsToCurrentPageRoute() 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() - } - - await waitForEventSubscription(target, domain: .network) - 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"}"# - ) - - 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.") - 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) -} - -@Test -func transportBackendDoesNotDeliverUnrelatedFrameNetworkEventsToCurrentPageRoute() 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() - } - } - defer { - eventTask.cancel() - } - - await waitForEventSubscription(target, domain: .network) - 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"}"# - ) - - try await Task.sleep(for: .milliseconds(100)) - #expect(await eventProbe.isFinished() == false) -} - -@Test -func transportBackendDoesNotDeliverFrameDocumentUpdatedToCurrentPageDOMRoute() 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() - } - } - defer { - eventTask.cancel() - } - - await waitForEventSubscription(target, domain: .dom) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "DOM.documentUpdated", - params: "{}" - ) - - try await Task.sleep(for: .milliseconds(100)) - #expect(await eventProbe.isFinished() == false) -} - -@Test -func transportBackendDecodesWebSocketHandshakeEventsForTargetRoute() 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 = pageTarget(proxy: proxy) - - let requestEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .network) - 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"}}}"# - ) - - let requestEvent = try #require(try await value(of: requestEventTask)) - guard case let .webSocket(.handshakeRequest(id, request, timestamp)) = requestEvent else { - Issue.record("Expected Network.webSocketWillSendHandshakeRequest.") - 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) - - let responseEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( - 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"}}}"# - ) - - 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) -} - -@Test -func transportBackendFiltersEventsByRoute() 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}}}"# - ) - let proxy = try await WebInspectorProxy(transport: transport) - let page = pageTarget(proxy: proxy) - let frame = WebInspectorTarget( - id: WebInspectorTarget.ID("frame-target"), - kind: .frame, - frameID: FrameID("frame-1"), - isProvisional: false, - proxy: proxy, - route: RoutingTargetID("frame-target") - ) - - 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() - } - - await waitForEventSubscription(page, domain: .network) - await waitForEventSubscription(frame, domain: .network) - await receiveTargetEvent( - 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}}"# - ) - - 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() -} - -@Test -func transportBackendKeepsPeerSubscriberActiveWhenOneStreamTerminates() 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 proxyBackend = try #require(proxy.structuredEventBackend as? LiveWebInspectorProxyBackend) - let page = pageTarget(proxy: proxy) - 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 receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.loadingFinished", - params: #"{"requestId":"first","timestamp":1}"# - ) - - 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 - ) - - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.loadingFinished", - params: #"{"requestId":"second","timestamp":2}"# - ) - try await value(of: Task { await probe.waitForSecondCount(2) }, timeout: transportCommandBackendWaitTimeout) - - secondSubscriber.cancel() -} - -@Test -func transportBackendRuntimeClearedUsesSemanticTargetID() 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 retargeted = WebInspectorTarget( - id: WebInspectorTarget.ID("semantic-page"), - kind: .page, - frameID: FrameID("main-frame"), - isProvisional: false, - proxy: proxy, - route: RoutingTargetID("page-main") - ) - - let eventTask = Task { - var iterator = retargeted.runtime.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(retargeted, domain: .runtime) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Runtime.executionContextsCleared", - params: "{}" - ) - - 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")) -} - @Test func transportCommandBackendDecodesRuntimeEvaluationResult() async throws { let backend = FakeTransportBackend() @@ -3076,45 +2068,6 @@ func structuredNetworkScopeRethrowsBodyFailureAfterSuccessfulCleanup() async thr #expect(await scopeTask.value == .bodyFailure) } -@Test -func legacyEventStreamFinishesOnlyAfterExplicitCloseCompletes() async throws { - let backend = FakeTransportBackend() - let closeGate = CloseConnectionGate() - let transport = TransportSession( - backend: backend, - responseTimeout: .milliseconds(750), - closeAction: { - await closeGate.waitUntilReleased() - } - ) - let stream = await transport.events(for: .network) - let nextStarted = CloseConnectionGate() - let nextCompletion = CompletionProbe() - let nextTask = Task { - var iterator = stream.makeAsyncIterator() - await nextStarted.waitUntilReleased() - let event = await iterator.next() - await nextCompletion.finish() - return event - } - await nextStarted.waitUntilStarted() - await nextStarted.release() - await Task.yield() - - let closeTask = Task { - await transport.close() - } - await closeGate.waitUntilStarted() - await Task.yield() - - #expect(await nextCompletion.isFinished() == false) - - await closeGate.release() - await closeTask.value - #expect(await nextTask.value == nil) - #expect(await nextCompletion.isFinished()) -} - @Test func structuredNetworkScopeEndsNormallyOnExplicitClose() async throws { let backend = FakeTransportBackend() @@ -4244,17 +3197,6 @@ private func installPageTarget( ) } -private func waitForEventSubscription( - _ target: WebInspectorTarget, - domain: WebInspectorProxyEventDomain -) async { - guard let proxy = target.proxyReference.resolve() else { - Issue.record("The test target lost its root proxy before subscription registration.") - return - } - await proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: domain) -} - private func receiveTargetEvent( _ transport: TransportSession, targetID: ProtocolTarget.ID, diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift index 24fa6f0d..f960c0ef 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift @@ -268,7 +268,7 @@ func proxyOwnsRawPeerAfterRuntimeWrapperIsReleased() async throws { let retainedProxy = try #require(proxy) let retainedPeer = try #require(weakPeer) let operation = Task { - try await retainedProxy.reload() + try await retainedProxy.page.page.reload() } let command = try await retainedPeer.commands.next() #expect(command.destination == .target("page-main")) From b0b43d620751e6920e8ceb576650b4233c8fa584 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:38:37 +0900 Subject: [PATCH 53/81] fix(datakit): complete scoped runtime clear integration --- .../WebInspectorDataKit/WebInspectorModelContext.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 02960675..9909fd0c 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -3268,8 +3268,13 @@ extension WebInspectorModelContext { extension WebInspectorModelContext { func apply( _ event: Runtime.Event, - targetID: WebInspectorTarget.ID? = nil + targetID: WebInspectorTarget.ID? = nil, + isCurrentPageTarget: Bool = true ) { - runtimeState.apply(event, sourceTargetID: targetID) + runtimeState.apply( + event, + sourceTargetID: targetID, + isCurrentPageTarget: isCurrentPageTarget + ) } } From e9fd4fcac966dba5adab297a5751d2f7215ca8a1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:45:00 +0900 Subject: [PATCH 54/81] fix(datakit): coalesce response body fetches --- .../WebInspectorDataKit/NetworkRequest.swift | 123 ++++++++++++-- .../NetworkRequestStore.swift | 14 +- .../WebInspectorModelContext.swift | 158 ++++++++---------- 3 files changed, 179 insertions(+), 116 deletions(-) diff --git a/Sources/WebInspectorDataKit/NetworkRequest.swift b/Sources/WebInspectorDataKit/NetworkRequest.swift index 6b242ee5..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) { @@ -769,7 +867,14 @@ public final class NetworkRequest: WebInspectorPersistentModel { 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: WebInspectorModelContext? @ObservationIgnored private var currentRequest: Network.Request @@ -1043,16 +1148,8 @@ public final class NetworkRequest: WebInspectorPersistentModel { 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/NetworkRequestStore.swift b/Sources/WebInspectorDataKit/NetworkRequestStore.swift index 414813e5..d0a4bb17 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestStore.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestStore.swift @@ -160,17 +160,6 @@ package final class NetworkRequestStore { request(for: NetworkRequest.ID(id)) } - package func finishResponseBodyFetch( - _ result: Result, - for request: NetworkRequest, - expectedBody: NetworkBody - ) { - guard requestsByID[request.id] === request else { - return - } - request.finishResponseBodyFetch(result: result, expectedBody: expectedBody) - } - package nonisolated(nonsending) func results( matching query: NetworkQuery, modelContext: WebInspectorModelContext @@ -1062,6 +1051,9 @@ package final class NetworkRequestStore { } private func prepareRemoveAllRequests() -> QueryIndexReset { + for request in requestsByID.values { + request.invalidateResponseBodyFetch() + } requestsByID = [:] orderedRequestIDs = [] orderIndicesByID = [:] diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 9909fd0c..628a3122 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -2500,65 +2500,70 @@ public final class WebInspectorModelContext { } /// Loads and returns the request's stable response-body resource. - public nonisolated(nonsending) func responseBody( - for request: NetworkRequest + /// + /// 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 - switch body.phase { - case .loaded: - return body - case let .failed(error): - throw error - case .fetching: - throw WebInspectorModelError.commandRejected( - method: "Network.getResponseBody", - message: "A response-body fetch is already in progress." - ) - case .available: + 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 page = try currentPageOrThrow() - body.markFetching() - do { - let payload = try await page.network.responseBody( - for: request.proxyID, - backendResourceIdentifier: request.backendResourceIdentifier - ) - networkRequests.finishResponseBodyFetch( - .success(payload), - for: request, - expectedBody: body - ) + + let lease: NetworkBody.ResponseFetchLease + switch body.acquireResponseFetch() { + case .loaded: return body - } catch let error as WebInspectorProxyError { - networkRequests.finishResponseBodyFetch( - .failure(error), - for: request, - expectedBody: body - ) + case let .failed(error): throw error - } catch { - let proxyError = WebInspectorProxyError.commandFailed( - domain: "Network", - method: "getResponseBody", - message: String(describing: error) - ) - networkRequests.finishResponseBodyFetch( - .failure(proxyError), - for: request, - expectedBody: body - ) - throw proxyError + 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( @@ -2581,60 +2586,29 @@ public final class WebInspectorModelContext { try await consoleMessages.update(query, for: results) } - func fetchResponseBody( - for request: NetworkRequest, - expectedBody: NetworkBody - ) async { - guard let currentPage else { - finishResponseBodyFetch( - .failure(.disconnected("WebInspectorDataKit has no current page target.")), - for: request, - expectedBody: expectedBody - ) - return - } - + private nonisolated static func loadResponseBody( + from page: WebInspectorTarget, + requestID: Network.Request.ID, + backendResourceIdentifier: Network.BackendResourceID? + ) async -> Result { do { - let body = try await currentPage.network.responseBody( - for: request.proxyID, - backendResourceIdentifier: request.backendResourceIdentifier - ) - finishResponseBodyFetch( - .success(body), - for: request, - expectedBody: expectedBody - ) + return .success(try await page.network.responseBody( + for: requestID, + backendResourceIdentifier: backendResourceIdentifier + )) + } catch is CancellationError { + return .failure(.staleIdentifier) } catch let error as WebInspectorProxyError { - finishResponseBodyFetch( - .failure(error), - for: request, - expectedBody: expectedBody - ) + return .failure(error) } catch { - finishResponseBodyFetch( - .failure(.commandFailed( - domain: "Network", - method: "getResponseBody", - message: String(describing: error) - )), - for: request, - expectedBody: expectedBody - ) + return .failure(.commandFailed( + domain: "Network", + method: "getResponseBody", + message: String(describing: error) + )) } } - private func finishResponseBodyFetch( - _ result: Result, - for request: NetworkRequest, - expectedBody: NetworkBody - ) { - networkRequests.finishResponseBodyFetch( - result, - for: request, - expectedBody: expectedBody - ) - } - private func makeRuntimeObjectGroup( named name: String? ) throws -> RuntimeObjectGroup { @@ -3223,7 +3197,7 @@ extension WebInspectorModelContext { ) } - package func apply( + package nonisolated(nonsending) func apply( _ event: Network.Event ) async { await networkRequests.apply(event, modelContext: self) From 34270552e0b6022ad2eb4ac118942f779c19d18a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:57:14 +0900 Subject: [PATCH 55/81] fix(datakit): enforce model owner and retry cleanup --- Scripts/verify-model-context-confinement.sh | 39 +++++++++++++++++++ Sources/WebInspectorDataKit/Fetching.swift | 15 ++++--- .../RuntimeObjectGroup.swift | 29 +++++++++----- .../WebInspectorModelContext.swift | 27 +++++++++++-- .../ModelContextCannotCrossActors.swift | 9 +++++ 5 files changed, 101 insertions(+), 18 deletions(-) create mode 100755 Scripts/verify-model-context-confinement.sh create mode 100644 Tests/ConcurrencyFixtures/ModelContextCannotCrossActors.swift 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/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 8e174643..f095db88 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -77,22 +77,26 @@ public final class WebInspectorFetchedResults] { - state.sections + modelContext?.preconditionOwnerIsolation() + return state.sections } /// The complete current section and item identity snapshot. public var snapshot: WebInspectorFetchedResultsSnapshot { - state.snapshot + modelContext?.preconditionOwnerIsolation() + return state.snapshot } /// Monotonically increasing publication revision. public var revision: UInt64 { - state.revision + modelContext?.preconditionOwnerIsolation() + return state.revision } init(modelContext: WebInspectorModelContext) { @@ -272,7 +276,8 @@ public final class WebInspectorFetchedResults AsyncStream> { - updateBroker.makeStream(initial: .initial( + modelContext?.preconditionOwnerIsolation() + return updateBroker.makeStream(initial: .initial( revision: state.revision, snapshot: state.snapshot )) diff --git a/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift b/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift index 16cb9ba6..5b8ae090 100644 --- a/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift +++ b/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift @@ -49,7 +49,11 @@ public final class RuntimeObjectGroup { _ expression: String, in context: RuntimeContext? = nil ) async throws -> RuntimeEvaluation { - guard !isClosed, let modelContext else { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { throw WebInspectorModelError.staleModel } return try await modelContext.evaluate( @@ -63,7 +67,11 @@ public final class RuntimeObjectGroup { of object: RuntimeObject, ownProperties: Bool = true ) async throws -> [RuntimeProperty] { - guard !isClosed, let modelContext else { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { throw WebInspectorModelError.staleModel } return try await modelContext.properties( @@ -76,22 +84,25 @@ public final class RuntimeObjectGroup { public nonisolated(nonsending) func preview( of object: RuntimeObject ) async throws -> RuntimeObjectPreview { - guard !isClosed, let modelContext else { + 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 !isClosed else { - return - } - defer { - isClosed = true - } 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/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 628a3122..ee18e112 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -72,6 +72,12 @@ private final class WebInspectorModelDeliveryBridge: @unchecked Sendable { 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? { @@ -576,7 +582,8 @@ public final class WebInspectorModelContext { } package var status: Status { - Status( + preconditionOwnerIsolation() + return Status( state: state, selectedNodeID: domState.selectedNode?.id, isElementPickerEnabled: domState.isElementPickerEnabled @@ -632,6 +639,10 @@ public final class WebInspectorModelContext { deliveryBridge.bind(self, isolation: isolation) } + package func preconditionOwnerIsolation() { + deliveryBridge.preconditionOwnerIsolation() + } + private func beginAttachment( to proxy: WebInspectorProxy ) -> ReplyPromise { @@ -1103,6 +1114,7 @@ public final class WebInspectorModelContext { } private nonisolated(nonsending) func tearDown(terminal: Bool) async { + preconditionOwnerIsolation() if isTerminallyClosed { return } @@ -1948,6 +1960,7 @@ public final class WebInspectorModelContext { /// Clears retained Network requests and emits reset transactions. public nonisolated(nonsending) func clearNetworkRequests() async { + preconditionOwnerIsolation() guard configuredDomains.contains(.network) else { return } @@ -2391,6 +2404,7 @@ public final class WebInspectorModelContext { public nonisolated(nonsending) func reload( ignoringCache: Bool = false ) async throws { + preconditionOwnerIsolation() let page = try currentPageOrThrow() try await page.page.reload(ignoringCache: ignoringCache) } @@ -2570,6 +2584,7 @@ public final class WebInspectorModelContext { _ query: NetworkQuery, for results: WebInspectorFetchedResults ) async throws { + preconditionOwnerIsolation() guard results.modelContext === self else { preconditionFailure("Network fetched results are not registered in this WebInspectorModelContext.") } @@ -2580,6 +2595,7 @@ public final class WebInspectorModelContext { _ query: ConsoleQuery, for results: WebInspectorFetchedResults ) async throws { + preconditionOwnerIsolation() guard results.modelContext === self else { preconditionFailure("Console fetched results are not registered in this WebInspectorModelContext.") } @@ -2701,25 +2717,27 @@ public final class WebInspectorModelContext { package nonisolated(nonsending) func close( objectGroup: RuntimeObjectGroup ) async throws { + preconditionOwnerIsolation() guard objectGroup.modelContext === self else { throw WebInspectorModelError.staleModel } - defer { - runtimeState.invalidateGroup(objectGroup.id) - } 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 } @@ -2822,6 +2840,7 @@ public final class WebInspectorModelContext { } private func requireConfigured(_ domain: Domain) throws { + preconditionOwnerIsolation() guard configuredDomains.contains(domain) else { throw WebInspectorModelError.domainNotConfigured(domain) } 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) +} From bf3a51a5d7d7a813002751e0ad1232b0ae3a9ae0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:57:38 +0900 Subject: [PATCH 56/81] fix(proxy): separate Inspector initialization replies --- .../Transport/TransportReplyStore.swift | 27 ++++++++++++ .../Transport/TransportSession.swift | 41 +++++++++++++------ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index d0d463ec..94a92a27 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -16,6 +16,11 @@ extension TransportSession { generation: WebInspectorPage.Generation, operationID: UInt64 ) + case capabilityAuxiliary( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) case modelBootstrap( feedID: ConnectionModelFeedID, generation: WebInspectorPage.Generation, @@ -109,6 +114,28 @@ extension TransportSession { ) } + 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, diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 8defe264..12923b94 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -3088,7 +3088,8 @@ package actor ConnectionCore { method: String, for key: ConnectionCapabilityKey, generation: WebInspectorPage.Generation, - operationID: UInt64 + operationID: UInt64, + publishesReplay: Bool = true ) throws -> ConnectionOwnedCommandOperation { let targetID: ProtocolTarget.ID switch key.route.storage { @@ -3120,15 +3121,27 @@ package actor ConnectionCore { message: message ) let promise = ReplyPromise() - let pendingReply = TransportSession.PendingReply.capability( - domain: domain, - method: method, - targetID: targetID, - promise: promise, - key: key, - generation: generation, - operationID: operationID - ) + 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, @@ -3205,7 +3218,8 @@ package actor ConnectionCore { method: "Inspector.initialized", for: key, generation: generation, - operationID: id + operationID: id, + publishesReplay: false ) } catch { return .failure( @@ -3989,7 +4003,8 @@ package actor ConnectionCore { ownership.purpose == pending.purpose, "A model command reply purpose does not match its operation owner." ) - case let .capability(_, _, operationID): + case let .capability(_, _, operationID), + let .capabilityAuxiliary(_, _, operationID): guard let ownership = capabilityTasks[operationID]?.pendingReplyOwnership else { preconditionFailure("A capability reply has no capability operation owner.") } @@ -4025,7 +4040,7 @@ package actor ConnectionCore { for pending: TransportSession.PendingReply ) throws { switch pending.purpose { - case .direct, .modelCommand: + case .direct, .modelCommand, .capabilityAuxiliary: // Consumer replies have no internal publication side effect. break case let .capability(key, generation, operationID): From 31f61b0f8f9bd4e792224cbe9baf23beeb1ca986 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:58:59 +0900 Subject: [PATCH 57/81] refactor(ui)!: make custom tab factories asynchronous --- Docs/MIGRATION.md | 21 ++ README.md | 8 +- .../CustomTabResourceViewController.swift | 140 ++++++++++ Sources/WebInspectorUI/README.md | 16 +- .../Tabs/BuiltInTabControllers.swift | 10 +- .../Tabs/PresentationContentStore.swift | 255 ++++++++++++++++++ .../WebInspectorUI/Tabs/WebInspectorTab.swift | 15 +- .../WebInspectorUI.docc/WebInspectorUI.md | 10 +- .../CustomTabResourceTests.swift | 164 +++++++++++ 9 files changed, 613 insertions(+), 26 deletions(-) create mode 100644 Sources/WebInspectorUI/CustomTabResourceViewController.swift create mode 100644 Tests/WebInspectorUITests/CustomTabResourceTests.swift diff --git a/Docs/MIGRATION.md b/Docs/MIGRATION.md index c69bbff4..32406c30 100644 --- a/Docs/MIGRATION.md +++ b/Docs/MIGRATION.md @@ -8,6 +8,27 @@ when upgrading WebInspectorKit. Sections are grouped by release, newest first. 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 diff --git a/README.md b/README.md index 4936c149..fe57dbc3 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,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( 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/README.md b/Sources/WebInspectorUI/README.md index d8834109..8c007947 100644 --- a/Sources/WebInspectorUI/README.md +++ b/Sources/WebInspectorUI/README.md @@ -13,11 +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 presentation model; it never belongs to the session. + 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 @@ -30,10 +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. Each `WebInspectorViewController` owns its -presentation resources and injects that store into compact or regular hosts. +`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 2dc17aa7..a1685df4 100644 --- a/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift +++ b/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift @@ -105,15 +105,13 @@ extension WebInspectorTab { } if case let .custom(content) = tab.content { - let viewController = contentStore.viewController( + let viewController = contentStore.customViewController( for: customContentKey(for: tab), - contextEpoch: session.interface.contextBoundContentRevision - ) { - content.makeViewController(session) - } + session: session, + makeViewController: content.makeViewController + ) switch hostLayout { case .compact: - viewController.webInspectorDetachFromContainerForReuse() return viewController case .regular: return RegularSplitRootViewController(contentViewController: viewController) diff --git a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift index 2c08e7f0..5a3b6d58 100644 --- a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -16,6 +16,12 @@ package final class PresentationContentStore { case failed(String) } + package enum CustomResourceStatus: Equatable, Sendable { + case loading + case ready + case failed(String) + } + private enum NetworkResourceState { case idle case loading(contextEpoch: Int, generation: UInt64) @@ -23,6 +29,21 @@ package final class PresentationContentStore { case failed(contextEpoch: Int, 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? @@ -31,6 +52,14 @@ package final class PresentationContentStore { } } + private final class WeakCustomResourceViewController { + weak var value: CustomTabResourceViewController? + + init(_ value: CustomTabResourceViewController) { + self.value = value + } + } + package typealias NetworkPanelModelFactory = @MainActor ( _ context: WebInspectorModelContext ) async throws -> NetworkPanelModel @@ -41,6 +70,21 @@ package final class PresentationContentStore { @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 contextEpoch: Int? package private(set) var networkResourceGeneration: UInt64 = 0 @@ -58,12 +102,20 @@ package final class PresentationContentStore { 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() } @@ -126,6 +178,32 @@ package final class PresentationContentStore { 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 + } + /// Begins a context transition synchronously. Any next Network load waits /// for the old load and model retirement before it can publish ready state. package func prepare(for contextEpoch: Int) { @@ -142,18 +220,165 @@ package final class PresentationContentStore { /// 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 contextEpoch = 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, contextEpoch: Int @@ -342,6 +567,36 @@ package final class PresentationContentStore { 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/WebInspectorTab.swift b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift index 8ec96ad3..83cc87be 100644 --- a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift +++ b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift @@ -5,7 +5,7 @@ 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: /// @@ -47,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 { @@ -89,15 +89,16 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { /// 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 - /// root inspector controller. WebInspectorKit caches the returned - /// controller for the tab ID and reuses it across that controller's compact - /// and regular hosts. + /// 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, requiredDomains: Set = [], - makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController + makeViewController: @escaping @MainActor (_ session: WebInspectorSession) async throws -> UIViewController ) { self.id = id self.title = title @@ -112,7 +113,7 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { title: String, systemImage: String, requiredDomains: Set = [], - makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController + makeViewController: @escaping @MainActor (_ session: WebInspectorSession) async throws -> UIViewController ) { self.init( id: id, 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/Tests/WebInspectorUITests/CustomTabResourceTests.swift b/Tests/WebInspectorUITests/CustomTabResourceTests.swift new file mode 100644 index 00000000..c1c85407 --- /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 var 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 From df9119d80125a1230bdda721d7e6ed2e95576b9a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:04:21 +0900 Subject: [PATCH 58/81] test: migrate model and picker contracts --- .../ConsoleConcreteQueryTests.swift | 27 +- .../ConsoleMessageStoreTests.swift | 567 +- .../DOMStateStoreTests.swift | 43 +- .../NetworkConcreteQueryTests.swift | 52 +- .../NetworkRequestStoreTests.swift | 103 +- .../RuntimeStateStoreTests.swift | 309 +- .../WebInspectorDataKitTests.swift | 8536 +---------------- .../ConnectionModelFeedTests.swift | 376 + .../WebInspectorProxyKitBackendTests.swift | 53 + 9 files changed, 1170 insertions(+), 8896 deletions(-) diff --git a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift index d9643140..1bd22183 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift @@ -13,7 +13,6 @@ func consoleQueryProvidesClosedDefaults() { #expect(query.offset == 0) #expect(query.limit == nil) } - @Test func consoleConcreteQueryRegistrationIncludesTheRequiredMutationSequence() async throws { let index = ConsoleMessageIndex() @@ -129,7 +128,7 @@ func cancelledConsoleConcreteQueryReplacementStopsWaitingWithoutTheMissingMutati @MainActor @Test func consoleConcreteQueryFiltersSortsSectionsAndWindowsCompactRecords() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let warning = makeIndexedConsoleMessage( id: 0, level: "warning", @@ -191,7 +190,7 @@ func consoleConcreteQueryFiltersSortsSectionsAndWindowsCompactRecords() async th @MainActor @Test func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = ConsoleMessageStore() let warningTarget = WebInspectorTarget.ID("concrete-warning-target") let logTarget = WebInspectorTarget.ID("concrete-log-target") @@ -221,7 +220,7 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically let warningID = ConsoleMessage.ID(0) let logID = ConsoleMessage.ID(1) let errorID = ConsoleMessage.ID(2) - let registeredWarning = store.message(for: warningID, isolation: MainActor.shared) + let registeredWarning = store.message(for: warningID) let warningIdentity = try #require(registeredWarning) let results = try await store.results( matching: ConsoleQuery( @@ -234,7 +233,6 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically limit: 2 ), modelContext: context, - isolation: MainActor.shared ) #expect(results.items.map(\.id) == [errorID, warningID]) #expect(results.sections.map(\.id.rawValue) == ["error", "warning"]) @@ -250,7 +248,6 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically targetID: warningTarget, modelContext: context, registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared ) #expect(results.items.last === warningIdentity) #expect(results.items.last?.repeatCount == 4) @@ -266,7 +263,6 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically targetID: warningTarget, modelContext: context, registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, - isolation: MainActor.shared ) #expect(results.items.map(\.id) == [errorID]) guard case let .transaction(_, clear, _)? = await updates.next() else { @@ -283,7 +279,6 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically limit: 1 ), for: results, - isolation: MainActor.shared ) #expect(results.items.map(\.id) == [logID]) #expect(results.sections.map(\.id) == [.defaultSection]) @@ -292,7 +287,7 @@ func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically @MainActor @Test func consoleConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = ConsoleMessageStore() let recordCount = 10_000 for ordinal in 0..? = try await store.results( matching: ConsoleQuery(), modelContext: context, - isolation: MainActor.shared ) weak let weakResults = results let registrationCount = await store.concreteQueryRegistrationCountForTesting( - isolation: MainActor.shared ) #expect(registrationCount == 1) results = nil #expect(weakResults == nil) let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( - isolation: MainActor.shared ) #expect(prunedRegistrationCount == 0) } @@ -380,7 +370,7 @@ private func makeIndexedConsoleMessage( id: Int, level: String, text: String, - context: WebInspectorContext + context: WebInspectorModelContext ) -> (id: ConsoleMessage.ID, input: ConsoleMessageRecordInput) { let modelID = ConsoleMessage.ID(id) let message = ConsoleMessage( @@ -403,7 +393,7 @@ private func addConsoleMessage( text: String, targetID: WebInspectorTarget.ID?, store: ConsoleMessageStore, - context: WebInspectorContext + context: WebInspectorModelContext ) async { _ = await store.apply( .messageAdded(Console.Message( @@ -414,6 +404,5 @@ private func addConsoleMessage( targetID: targetID, modelContext: context, registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared ) } diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift index 62742bdb..730a811c 100644 --- a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -1,11 +1,10 @@ -import Foundation import Testing @testable import WebInspectorDataKit import WebInspectorProxyKit private actor StoreIsolationProbe { - func exercise() async -> (networkCount: Int, consoleItemCount: Int) { - let context = WebInspectorContext.preview(isolation: self) + 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( @@ -20,16 +19,14 @@ private actor StoreIsolationProbe { redirectResponse: nil, timestamp: 1 ), - modelContext: context, - isolation: self + modelContext: context ) let consoleStore = ConsoleMessageStore() - let consoleResults = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor(), + let results = try await consoleStore.results( + matching: ConsoleQuery(), modelContext: context ) - consoleStore.register(consoleResults, modelContext: context, isolation: self) _ = await consoleStore.apply( .messageAdded(Console.Message( source: Console.Source(rawValue: "console-api"), @@ -38,560 +35,62 @@ private actor StoreIsolationProbe { )), targetID: nil, modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: self + registerRuntimeObject: { _ in + fatalError("The fixture has no Runtime parameters.") + } + ) + return ( + networkStore.collectionState.requestCount, + results.items.map(\.text) ) - return (networkStore.collectionState.requestCount, consoleResults.items.count) } } @Test -func networkAndConsoleStoresFollowTheCallingActor() async { - let values = await StoreIsolationProbe().exercise() +func networkAndConsoleStoresInheritTheCallingActor() async throws { + let values = try await StoreIsolationProbe().exercise() #expect(values.networkCount == 1) - #expect(values.consoleItemCount == 1) -} - -@MainActor -@Test -func consoleMessageQueryPlanKeepsSupportedPredicatesOffTheModelActor() { - let descriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { message in - message.level.rawValue == "warning" - }, - sortBy: [SortDescriptor(\.text, order: .reverse)], - fetchLimit: 100 - ) - - let plan = ConsoleMessageQueryPlan(descriptor: descriptor) - - #expect(plan.requiresQuery) - #expect(plan.requiresModelPredicate == false) - #expect(plan.requiresModelQuery == false) + #expect(values.consoleTexts == ["custom actor"]) } @MainActor @Test -func consoleMessageSortPlanningPreservesStandardAndCustomComparators() async { - let context = WebInspectorContext.preview(isolation: MainActor.shared) +func consoleMessageStoreClearsOnlyTheAddressedTarget() async throws { + let context = WebInspectorModelContext.preview() let store = ConsoleMessageStore() - let customDescriptor = WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.text, comparator: .lexical)] - ) - let customPlan = ConsoleMessageQueryPlan(descriptor: customDescriptor) - #expect(customPlan.requiresModelQuery) - #expect(customPlan.sortComparators.isEmpty) - #expect(customPlan.modelSortDescriptors?.count == 1) - let standardDescriptor = WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.text)] - ) - let standardPlan = ConsoleMessageQueryPlan(descriptor: standardDescriptor) - #expect(standardPlan.requiresModelQuery == false) - #expect(standardPlan.sortComparators.count == 1) - #expect(standardPlan.modelSortDescriptors == nil) - - let customResults = WebInspectorFetchedResults( - fetchDescriptor: customDescriptor, - modelContext: context - ) - let standardResults = WebInspectorFetchedResults( - fetchDescriptor: standardDescriptor, + let firstTarget = WebInspectorTarget.ID("first") + let secondTarget = WebInspectorTarget.ID("second") + let results = try await store.results( + matching: ConsoleQuery(), modelContext: context ) - store.register(customResults, modelContext: context, isolation: MainActor.shared) - store.register(standardResults, modelContext: context, isolation: MainActor.shared) - for text in ["item2", "item10"] { + + 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: nil, + targetID: target, modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - } - - #expect(customResults.items.map(\.text) == ["item10", "item2"]) - #expect(standardResults.items.map(\.text) == ["item2", "item10"]) -} - -@MainActor -@Test -func consoleMessageIndexDrainsMutationsInSequenceOrder() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let first = ConsoleMessage( - id: ConsoleMessage.ID(0), - message: Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "first" - ), - parameters: [], - targetID: nil, - modelContext: context - ) - let second = ConsoleMessage( - id: ConsoleMessage.ID(1), - message: Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "second" - ), - parameters: [], - targetID: nil, - modelContext: context - ) - let index = ConsoleMessageIndex() - - let secondMutation = Task { - await index.upsert( - ConsoleMessageRecordInput(message: second, orderIndex: 1), - sequence: 2 + registerRuntimeObject: { _ in + fatalError("The fixture has no Runtime parameters.") + } ) } - for _ in 0..<1_000 { - if await index.isMutationPendingForTesting(sequence: 2) { - break - } - await Task.yield() - } - let secondMutationIsPending = await index.isMutationPendingForTesting(sequence: 2) - #expect(secondMutationIsPending) - await index.upsert( - ConsoleMessageRecordInput(message: first, orderIndex: 0), - sequence: 1 - ) - _ = await secondMutation.value - - let delta = await index.delta( - plan: ConsoleMessageQueryPlan(descriptor: WebInspectorFetchDescriptor()), - sectionBy: nil, - oldSnapshot: WebInspectorFetchedResultsSnapshot(), - changedSince: 0 - ) - #expect(delta.sequence == 2) - #expect(delta.snapshot.itemIDs == [first.id, second.id]) -} - -@MainActor -@Test -func consoleMessageStoreOwnsTargetOrderAndClearEffects() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = ConsoleMessageStore() - let firstTargetID = WebInspectorTarget.ID("first-target") - let secondTargetID = WebInspectorTarget.ID("second-target") - - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "first" - )), - targetID: firstTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "warning"), - text: "second" - )), - targetID: secondTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "page" - )), - targetID: nil, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - _ = await store.apply( - .messageRepeatCountUpdated(count: 3, timestamp: 4), - targetID: firstTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - _ = await store.apply( - .messageRepeatCountUpdated(count: 9, timestamp: 5), - targetID: WebInspectorTarget.ID("unknown-target"), - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - _ = await store.apply( - .messageRepeatCountUpdated(count: 4, timestamp: 6), - targetID: nil, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - #expect(results.items.map(\.text) == ["first", "second", "page"]) - #expect(results.items.map(\.repeatCount) == [3, 1, 4]) - let firstMessageID = try #require(results.items.first?.id) + #expect(results.items.map(\.text) == ["first", "second"]) let effects = await store.apply( .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: firstTargetID, + targetID: firstTarget, modelContext: context, - registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, - isolation: MainActor.shared + registerRuntimeObject: { _ in + fatalError("Clear events have no Runtime parameters.") + } ) #expect(effects.clearedAllMessages == false) - #expect(results.items.map(\.text) == ["second", "page"]) - let clearedMessage = store.message(for: firstMessageID, isolation: MainActor.shared) - #expect(clearedMessage == nil) -} - -@MainActor -@Test -func consoleMessageTransactionsPreserveInsertMoveAndUpdateSemantics() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = ConsoleMessageStore() - let firstTargetID = WebInspectorTarget.ID("sorted-first-target") - let secondTargetID = WebInspectorTarget.ID("sorted-second-target") - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.repeatCount, order: .reverse)] - ), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - var updates = results.updates().makeAsyncIterator() - guard case .initial? = await updates.next() else { - Issue.record("Expected the initial Console fetched-results snapshot.") - return - } - - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "first", - repeatCount: 1 - )), - targetID: firstTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - let firstID = try #require(results.items.first?.id) - guard case let .transaction(_, firstInsert, firstReconfigure)? = await updates.next() else { - Issue.record("Expected the first Console insertion transaction.") - return - } - #expect(firstInsert.itemChanges == [ - .insert( - itemID: firstID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) - #expect(firstReconfigure.isEmpty) - - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "second", - repeatCount: 2 - )), - targetID: secondTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - let secondID = try #require(results.items.first?.id) - guard case let .transaction(_, frontInsert, frontReconfigure)? = await updates.next() else { - Issue.record("Expected the front insertion transaction.") - return - } - #expect(results.items.map(\.id) == [secondID, firstID]) - #expect(frontInsert.itemChanges == [ - .insert( - itemID: secondID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) - #expect(frontReconfigure.isEmpty) - - _ = await store.apply( - .messageRepeatCountUpdated(count: 3, timestamp: 3), - targetID: firstTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - guard case let .transaction(_, move, moveReconfigure)? = await updates.next() else { - Issue.record("Expected the Console sort-move transaction.") - return - } - #expect(results.items.map(\.id) == [firstID, secondID]) - #expect(move.itemChanges == [ - .move( - itemID: firstID, - from: WebInspectorFetchedResultsIndexPath(section: 0, item: 1), - to: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) - #expect(moveReconfigure == [firstID]) - - _ = await store.apply( - .messageRepeatCountUpdated(count: 4, timestamp: 4), - targetID: firstTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - guard case let .transaction(_, update, updateReconfigure)? = await updates.next() else { - Issue.record("Expected the in-place Console update transaction.") - return - } - #expect(update.itemChanges == [ - .update( - itemID: firstID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) - #expect(updateReconfigure == [firstID]) -} - -@MainActor -@Test -func consoleMessageReverseSortPreservesInsertionOrderForEqualKeys() async { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = ConsoleMessageStore() - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.level.rawValue, order: .reverse)], - fetchLimit: 1 - ), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - - for text in ["first", "second"] { - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: text - )), - targetID: nil, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - } - - #expect(results.items.map(\.text) == ["first"]) -} - -@MainActor -@Test -func consoleMessageTransactionsPreserveSectionInsertionAndDeletion() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = ConsoleMessageStore() - let warningTargetID = WebInspectorTarget.ID("section-warning-target") - let errorTargetID = WebInspectorTarget.ID("section-error-target") - let results = WebInspectorFetchedResults( - fetchDescriptor: WebInspectorFetchDescriptor(), - sectionBy: WebInspectorSectionDescriptor(\.level), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - var updates = results.updates().makeAsyncIterator() - guard case .initial? = await updates.next() else { - Issue.record("Expected the initial sectioned Console snapshot.") - return - } - - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "warning" - )), - targetID: warningTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - let warningID = try #require(results.items.first?.id) - guard case let .transaction(_, warningInsert, _)? = await updates.next() else { - Issue.record("Expected the warning-section insertion transaction.") - return - } - #expect(warningInsert.sectionChanges == [ - .insert(sectionID: WebInspectorFetchSectionID(rawValue: "warning"), index: 0), - ]) - #expect(warningInsert.itemChanges == [ - .insert( - itemID: warningID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) - - _ = await store.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "error"), - text: "error" - )), - targetID: errorTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, - isolation: MainActor.shared - ) - let errorID = try #require(results.items.last?.id) - guard case let .transaction(_, errorInsert, _)? = await updates.next() else { - Issue.record("Expected the error-section insertion transaction.") - return - } - #expect(errorInsert.sectionChanges == [ - .insert(sectionID: WebInspectorFetchSectionID(rawValue: "error"), index: 1), - ]) - #expect(errorInsert.itemChanges == [ - .insert( - itemID: errorID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 1, item: 0) - ), - ]) - - _ = await store.apply( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: warningTargetID, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, - isolation: MainActor.shared - ) - guard case let .transaction(_, warningDelete, _)? = await updates.next() else { - Issue.record("Expected the warning-section deletion transaction.") - return - } - #expect(results.items.map(\.id) == [errorID]) - #expect(warningDelete.sectionChanges == [ - .delete(sectionID: WebInspectorFetchSectionID(rawValue: "warning"), index: 0), - ]) - #expect(warningDelete.itemChanges == [ - .delete( - itemID: warningID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ), - ]) -} - -@MainActor -@Test -func consoleMessageTransactionPublishesMembershipChangeFromDeletedSection() { - let firstID = ConsoleMessage.ID(0) - let secondID = ConsoleMessage.ID(1) - let warningSection = WebInspectorFetchSectionID(rawValue: "warning") - let errorSection = WebInspectorFetchSectionID(rawValue: "error") - let oldSnapshot = WebInspectorFetchedResultsSnapshot(sections: [ - WebInspectorFetchedResultsSnapshot.Section( - id: warningSection, - title: "warning", - itemIDs: [firstID] - ), - WebInspectorFetchedResultsSnapshot.Section( - id: errorSection, - title: "error", - itemIDs: [secondID] - ), - ]) - let newSnapshot = WebInspectorFetchedResultsSnapshot(sections: [ - WebInspectorFetchedResultsSnapshot.Section( - id: warningSection, - title: "warning", - itemIDs: [firstID, secondID] - ), - ]) - - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot - ) - - #expect(transaction.sectionChanges == [ - .delete(sectionID: errorSection, index: 1), - ]) - #expect(transaction.itemChanges == [ - .insert( - itemID: secondID, - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1) - ), - ]) -} - -@MainActor -@Test -func consoleMessageStoreTenThousandLiveInsertsAndMutationUseCompactProjections() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = ConsoleMessageStore() - let recordCount = 10_000 - - for ordinal in 0..( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - #expect(results.items.count == recordCount) - store.resetPerformanceCountersForTesting(isolation: MainActor.shared) - - _ = await store.apply( - .messageRepeatCountUpdated(count: 7, timestamp: Double(recordCount)), - targetID: nil, - modelContext: context, - registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, - isolation: MainActor.shared - ) - - let mutationCounters = store.performanceCountersForTesting - #expect(mutationCounters.incrementalRecordProjectionCount == 1) - #expect(mutationCounters.fullRecordProjectionCount == 0) - #expect(mutationCounters.fullModelProjectionCount == 0) - #expect(mutationCounters.resultIdentityLookupCount == 0) - #expect(results.items.count == recordCount) - #expect(results.items.last?.repeatCount == 7) + #expect(results.items.map(\.text) == ["second"]) } diff --git a/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift index 0868e670..61a0c168 100644 --- a/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift @@ -5,7 +5,7 @@ import WebInspectorProxyKit @MainActor @Test func domStateStorePreservesNodeIdentityAcrossPayloadUpdates() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let documentID = DOM.Node.ID("document") let elementID = DOM.Node.ID("element") @@ -28,7 +28,6 @@ func domStateStorePreservesNodeIdentityAcrossPayloadUpdates() throws { expectedEpoch: store.documentEpoch, reason: .initialDocument, modelContext: context, - isolation: MainActor.shared ) #expect(applied != nil) let original = try #require(store.node(for: DOMNode.ID(elementID))) @@ -36,17 +35,15 @@ func domStateStorePreservesNodeIdentityAcrossPayloadUpdates() throws { _ = store.apply( .attributeModified(elementID, name: "class", value: "after"), modelContext: context, - isolation: MainActor.shared ) #expect(store.node(for: DOMNode.ID(elementID)) === original) #expect(original.attributes["class"] == "after") } - @MainActor @Test func domStateStoreDocumentResetAdvancesEpochAndClearsSemanticState() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let childID = DOM.Node.ID("selected") _ = store.applyDocument( @@ -59,17 +56,15 @@ func domStateStoreDocumentResetAdvancesEpochAndClearsSemanticState() throws { expectedEpoch: store.documentEpoch, reason: .initialDocument, modelContext: context, - isolation: MainActor.shared ) let child = try #require(store.node(for: DOMNode.ID(childID))) - _ = store.select(child, reveal: .none, isolation: MainActor.shared) - _ = store.setElementPickerEnabled(true, isolation: MainActor.shared) + _ = store.select(child, reveal: .none) + _ = store.setElementPickerEnabled(true) let previousEpoch = store.documentEpoch let effects = store.apply( .documentUpdated, modelContext: context, - isolation: MainActor.shared ) #expect(store.documentEpoch == previousEpoch + 1) @@ -84,7 +79,7 @@ func domStateStoreDocumentResetAdvancesEpochAndClearsSemanticState() throws { @MainActor @Test func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let frameTargetID = WebInspectorTarget.ID("frame-target") let frameID = FrameID("child-frame") @@ -109,7 +104,6 @@ func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { expectedEpoch: store.documentEpoch, reason: .initialDocument, modelContext: context, - isolation: MainActor.shared ) let applied = store.applyFrameDocument( @@ -122,7 +116,6 @@ func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { frameTargetID: frameTargetID, expectedEpoch: store.documentEpoch, modelContext: context, - isolation: MainActor.shared ) #expect(applied != nil) @@ -130,7 +123,7 @@ func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { frameDocumentID.rawValue, scopedToTargetRawValue: frameTargetID.rawValue )) - let snapshot = store.currentTreeSnapshot(isolation: MainActor.shared) + let snapshot = store.currentTreeSnapshot() #expect(snapshot.parent(of: scopedDocumentID) == DOMNode.ID(iframeID)) #expect(snapshot.visibleChildren(of: DOMNode.ID(iframeID)).nodeIDs == [scopedDocumentID]) } @@ -138,7 +131,7 @@ func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { @MainActor @Test func domStateStorePublishesSelectionDeltaAndRevealFromOneMutation() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let parentID = DOM.Node.ID("parent") let childID = DOM.Node.ID("child") @@ -160,9 +153,8 @@ func domStateStorePublishesSelectionDeltaAndRevealFromOneMutation() async throws expectedEpoch: store.documentEpoch, reason: .initialDocument, modelContext: context, - isolation: MainActor.shared ) - let controller = store.rootTreeController(isolation: MainActor.shared) + let controller = store.rootTreeController() var updateIterator = controller.updates.makeAsyncIterator() var revealIterator = controller.revealRequests.makeAsyncIterator() guard case .snapshot? = await updateIterator.next() else { @@ -171,7 +163,7 @@ func domStateStorePublishesSelectionDeltaAndRevealFromOneMutation() async throws } let child = try #require(store.node(for: DOMNode.ID(childID))) - _ = store.select(child, reveal: .selectOnly, isolation: MainActor.shared) + _ = store.select(child, reveal: .selectOnly) #expect(await updateIterator.next() == .delta(.selectionChanged(nodeID: child.id))) #expect(await revealIterator.next() == DOMTreeRevealRequest( @@ -185,7 +177,7 @@ func domStateStorePublishesSelectionDeltaAndRevealFromOneMutation() async throws @MainActor @Test func domStateStoreTreeSubscriptionBridgesInitialSnapshotAndNextDeltaAtomically() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let elementID = DOM.Node.ID("element") _ = store.applyDocument( @@ -198,16 +190,14 @@ func domStateStoreTreeSubscriptionBridgesInitialSnapshotAndNextDeltaAtomically() expectedEpoch: store.documentEpoch, reason: .initialDocument, modelContext: context, - isolation: MainActor.shared ) - let controller = store.rootTreeController(isolation: MainActor.shared) + let controller = store.rootTreeController() let initialRevision = controller.revision var iterator = controller.updates.makeAsyncIterator() _ = store.apply( .attributeModified(elementID, name: "class", value: "updated"), modelContext: context, - isolation: MainActor.shared ) guard case let .snapshot(snapshot, reason)? = await iterator.next() else { @@ -223,18 +213,17 @@ func domStateStoreTreeSubscriptionBridgesInitialSnapshotAndNextDeltaAtomically() @MainActor @Test func domStateStoreRejectsDocumentFromStaleEpoch() { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = DOMStateStore() let staleEpoch = store.documentEpoch - store.advanceDocumentEpoch(isolation: MainActor.shared) - _ = store.resetDocument(isolation: MainActor.shared) + 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, - isolation: MainActor.shared ) #expect(applied == nil) @@ -248,11 +237,11 @@ func domStateStoreKeepsOnlyWeakTreeRegistrations() { weak var releasedController: DOMTreeController? do { - let controller = store.rootTreeController(isolation: MainActor.shared) + let controller = store.rootTreeController() releasedController = controller #expect(releasedController != nil) } #expect(releasedController == nil) - _ = store.resetDocument(isolation: MainActor.shared) + _ = store.resetDocument() } diff --git a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift index eeccd9c3..bcfbb40a 100644 --- a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift +++ b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift @@ -22,7 +22,7 @@ func networkQueryNormalizesSearchAndProvidesClosedDefaults() { @MainActor @Test func networkConcreteQueryUsesInsertionOrderToBreakEqualRequestTimes() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() var first = makeIndexedNetworkRecord( id: "tie-first", url: "https://example.com/first", @@ -70,7 +70,7 @@ func networkConcreteQueryUsesInsertionOrderToBreakEqualRequestTimes() async thro @MainActor @Test func networkConcreteQueryRegistrationIncludesMutationAppliedWhileWaitingForInitialState() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let first = makeIndexedNetworkRecord( id: "initial-first", url: "https://example.com/first", @@ -159,7 +159,7 @@ func cancelledNetworkConcreteQueryRegistrationStopsWaitingWithoutTheMissingMutat @MainActor @Test func networkConcreteQueryReplacementAbsorbsMutationBetweenPrepareAndCommit() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let first = makeIndexedNetworkRecord( id: "replacement-first", url: "https://example.com/first", @@ -218,7 +218,7 @@ func networkConcreteQueryReplacementAbsorbsMutationBetweenPrepareAndCommit() asy @MainActor @Test func cancelledNetworkConcreteQueryReplacementLeavesTheActiveGenerationWhole() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let first = makeIndexedNetworkRecord( id: "cancel-first", url: "https://example.com/first", @@ -277,7 +277,7 @@ func cancelledNetworkConcreteQueryReplacementLeavesTheActiveGenerationWhole() as @MainActor @Test func overlappingNetworkConcreteQueryReplacementsCommitOnlyTheNewestGeneration() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let first = makeIndexedNetworkRecord( id: "overlap-first", url: "https://example.com/first", @@ -337,8 +337,8 @@ func overlappingNetworkConcreteQueryReplacementsCommitOnlyTheNewestGeneration() @MainActor @Test -func concreteFetchedResultsNeverRegressTheirSourceEpoch() { - let context = WebInspectorContext.preview(isolation: MainActor.shared) +func concreteFetchedResultsNeverRegressTheirSourceEpoch() throws { + let context = WebInspectorModelContext.preview() let first = makeIndexedNetworkRecord( id: "epoch-first", url: "https://example.com/first", @@ -353,16 +353,13 @@ func concreteFetchedResultsNeverRegressTheirSourceEpoch() { timestamp: 2, context: context ) - guard let firstModel = context.registeredRequest(for: first.id), - let secondModel = context.registeredRequest(for: second.id) else { + 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( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: context - ) + let results = WebInspectorFetchedResults(modelContext: context) results.installInitialNetworkQuery( NetworkQuery(), generation: 1, @@ -409,7 +406,7 @@ func concreteFetchedResultsNeverRegressTheirSourceEpoch() { @MainActor @Test func networkConcreteQueryPublishesSameIdentityMoveSectionsWindowAndClear() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = NetworkRequestStore() let firstProxyID = Network.Request.ID("store-first") let secondProxyID = Network.Request.ID("store-second") @@ -439,13 +436,11 @@ func networkConcreteQueryPublishesSameIdentityMoveSectionsWindowAndClear() async limit: 2 ), modelContext: context, - isolation: MainActor.shared ) let firstID = NetworkRequest.ID(firstProxyID) let secondID = NetworkRequest.ID(secondProxyID) let registeredFirst = store.request( forProxyID: firstProxyID, - isolation: MainActor.shared ) let firstIdentity = try #require(registeredFirst) #expect(results.items.map(\.id) == [firstID, secondID]) @@ -482,12 +477,11 @@ func networkConcreteQueryPublishesSameIdentityMoveSectionsWindowAndClear() async limit: 1 ), for: results, - isolation: MainActor.shared ) #expect(results.items.map(\.id) == [firstID]) #expect(results.sections.map(\.id.rawValue) == ["PUT"]) - await store.clear(isolation: MainActor.shared) + await store.clear() #expect(results.items.isEmpty) #expect(results.sections.isEmpty) #expect(results.snapshot.itemIDs.isEmpty) @@ -496,7 +490,7 @@ func networkConcreteQueryPublishesSameIdentityMoveSectionsWindowAndClear() async @MainActor @Test func networkConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = NetworkRequestStore() let recordCount = 10_000 for ordinal in 0..? = try await store.results( matching: NetworkQuery(), modelContext: context, - isolation: MainActor.shared ) weak let weakResults = results let registrationCount = await store.concreteQueryRegistrationCountForTesting( - isolation: MainActor.shared ) #expect(registrationCount == 1) results = nil #expect(weakResults == nil) let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( - isolation: MainActor.shared ) #expect(prunedRegistrationCount == 0) } @@ -588,7 +577,7 @@ private func makeIndexedNetworkRecord( url: String, method: String, timestamp: Double, - context: WebInspectorContext + context: WebInspectorModelContext ) -> (id: NetworkRequest.ID, input: NetworkRequestRecordInput) { let modelID = context.seedNetworkRequest( requestID: id, @@ -599,9 +588,8 @@ private func makeIndexedNetworkRecord( responseStatus: 200, responseStatusText: "OK", timestamp: timestamp, - isolation: MainActor.shared ) - guard let request = context.registeredRequest(for: modelID, isolation: MainActor.shared) else { + 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))) @@ -614,7 +602,7 @@ private func addNetworkRequest( method: String, timestamp: Double, store: NetworkRequestStore, - context: WebInspectorContext + context: WebInspectorModelContext ) async { await store.apply( .requestWillBeSent( @@ -625,7 +613,6 @@ private func addNetworkRequest( timestamp: timestamp ), modelContext: context, - isolation: MainActor.shared ) } @@ -634,7 +621,7 @@ private func finishNetworkRequest( _ id: Network.Request.ID, timestamp: Double, store: NetworkRequestStore, - context: WebInspectorContext + context: WebInspectorModelContext ) async { await store.apply( .loadingFinished( @@ -644,7 +631,6 @@ private func finishNetworkRequest( metrics: nil ), modelContext: context, - isolation: MainActor.shared ) } diff --git a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift index ae20ba61..57874990 100644 --- a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift @@ -5,7 +5,7 @@ import WebInspectorProxyKit @MainActor @Test func networkRequestStoreOwnsIdentityOrderAndClearEpoch() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() let store = NetworkRequestStore() let firstProxyID = Network.Request.ID("first") let secondProxyID = Network.Request.ID("second") @@ -13,111 +13,54 @@ func networkRequestStoreOwnsIdentityOrderAndClearEpoch() async throws { await store.apply( .requestWillBeSent( id: firstProxyID, - request: Network.Request(id: firstProxyID, url: "https://example.com/first", method: "GET"), + request: Network.Request( + id: firstProxyID, + url: "https://example.com/first", + method: "GET" + ), resourceType: .fetch, redirectResponse: nil, timestamp: 1 ), - modelContext: context, - isolation: MainActor.shared + modelContext: context ) - let registeredFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) - let first = try #require(registeredFirst) + let first = try #require(store.request(forProxyID: firstProxyID)) await store.apply( .responseReceived( id: firstProxyID, - response: Network.Response(url: "https://example.com/first", status: 200), + response: Network.Response( + url: "https://example.com/first", + status: 200 + ), resourceType: .fetch, timestamp: 2 ), - modelContext: context, - isolation: MainActor.shared + modelContext: context ) await store.apply( .requestWillBeSent( id: secondProxyID, - request: Network.Request(id: secondProxyID, url: "https://example.com/second", method: "POST"), + request: Network.Request( + id: secondProxyID, + url: "https://example.com/second", + method: "POST" + ), resourceType: .fetch, redirectResponse: nil, timestamp: 3 ), - modelContext: context, - isolation: MainActor.shared + modelContext: context ) - let updatedFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) - #expect(updatedFirst === first) + #expect(store.request(forProxyID: firstProxyID) === first) #expect(first.statusCode == 200) #expect(store.collectionState.requestCount == 2) - await store.clear(isolation: MainActor.shared) + await store.clear() - let clearedFirst = store.request(forProxyID: firstProxyID, isolation: MainActor.shared) - let clearedSecond = store.request(forProxyID: secondProxyID, isolation: MainActor.shared) - #expect(clearedFirst == nil) - #expect(clearedSecond == nil) + #expect(store.request(forProxyID: firstProxyID) == nil) + #expect(store.request(forProxyID: secondProxyID) == nil) #expect(store.collectionState.requestCount == 0) #expect(store.collectionState.topologyRevision == 3) } - -@MainActor -@Test -func networkRequestStorePropertyMutationAtTenThousandRecordsUsesOneCompactProjection() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = NetworkRequestStore() - let recordCount = 10_000 - - for ordinal in 0..( - fetchDescriptor: WebInspectorFetchDescriptor(), - modelContext: context - ) - store.register(results, modelContext: context, isolation: MainActor.shared) - #expect(results.items.count == recordCount) - store.resetPerformanceCountersForTesting(isolation: MainActor.shared) - - let lastID = Network.Request.ID("request-\(recordCount - 1)") - await store.apply( - .dataReceived( - id: lastID, - dataLength: 1, - encodedDataLength: 1, - timestamp: Double(recordCount) - ), - modelContext: context, - isolation: MainActor.shared - ) - - let counters = store.performanceCountersForTesting - #expect(counters.incrementalRecordProjectionCount == 1) - #expect(counters.fullRecordProjectionCount == 0) - #expect(counters.fullModelProjectionCount == 0) - #expect(counters.resultIdentityLookupCount == 0) - #expect(results.items.count == recordCount) - #expect(results.items.last?.encodedDataLength == 1) -} diff --git a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift index 80660cd6..43d2d790 100644 --- a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift @@ -12,15 +12,14 @@ private actor RuntimeStateStoreIsolationProbe { name: "Custom actor", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: self + sourceTargetID: WebInspectorTarget.ID("page") ) return (store.executionContexts.count, store.selectedContext?.name) } } @Test -func runtimeStateStoreFollowsCustomActorWithoutRetainingIt() async { +func runtimeStateStoreFollowsItsCallerActorWithoutRetainingIt() async { var probe: RuntimeStateStoreIsolationProbe? = RuntimeStateStoreIsolationProbe() weak let releasedProbe = probe @@ -48,8 +47,7 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "First", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) let first = try #require(store.executionContexts.first) #expect(store.selectedContext === first) @@ -60,8 +58,7 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "First updated", kind: .user )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) #expect(store.executionContexts == [first]) #expect(first.name == "First updated") @@ -73,17 +70,15 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "Second", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) let second = try #require(store.executionContexts.last) - store.select(second, isolation: MainActor.shared) + store.select(second) #expect(store.selectedContext === second) store.apply( .executionContextDestroyed(secondID), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) #expect(store.executionContexts == [first]) #expect(store.selectedContext === first) @@ -91,106 +86,106 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { @MainActor @Test -func runtimeStateStoreRemovesOwnershipOnlyForTheRegisteredObjectIdentity() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) +func runtimeStateStoreKeepsGroupOwnershipAfterConsoleOwnershipEnds() throws { let store = RuntimeStateStore() - let remoteObject = Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("shared-id"), - kind: .object, - description: "registered" - ) - let registered = store.registerConsoleParameter( - remoteObject, - modelContext: context, - isolation: MainActor.shared + let groupID = store.createGroupID() + let remoteID = Runtime.RemoteObject.ID("shared") + let consoleObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "console") ) - let impostor = RuntimeObject( - id: registered.id, - remoteObject: remoteObject, - modelContext: context + 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 ) - store.removeConsoleOwnership(from: [impostor], isolation: MainActor.shared) - #expect(try store.objectBinding(for: registered, isolation: MainActor.shared)?.remoteID == remoteObject.id) + #expect(evaluation.object === consoleObject) + #expect(consoleObject.description == "group") + store.removeConsoleOwnership(from: [consoleObject]) + #expect(try store.objectBinding(for: consoleObject, groupID: groupID)?.remoteID == remoteID) - store.removeConsoleOwnership(from: [registered], isolation: MainActor.shared) - #expect(throws: WebInspectorProxyError.disconnected( - "RuntimeObject is not registered in this WebInspectorContext." - )) { - try store.objectBinding(for: registered, isolation: MainActor.shared) + store.invalidateGroup(groupID) + #expect(throws: WebInspectorModelError.staleModel) { + try store.objectBinding(for: consoleObject, groupID: groupID) } } @MainActor @Test -func runtimeStateStorePreservesClientOwnershipAfterConsoleOwnershipEnds() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) +func runtimeStateStoreOldPointerCannotRemoveSameRemoteIDReplacement() throws { let store = RuntimeStateStore() - let objectID = Runtime.RemoteObject.ID("client-and-console") - let consoleObject = store.registerConsoleParameter( - Runtime.RemoteObject(id: objectID, kind: .object, description: "console"), - modelContext: context, - isolation: MainActor.shared + let remoteID = Runtime.RemoteObject.ID("reused-remote-id") + let oldObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "old") ) - let binding = try store.evaluationBinding(for: nil, isolation: MainActor.shared) - let evaluation = try store.finishEvaluation( + + 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: objectID, kind: .object, description: "client") + object: Runtime.RemoteObject(id: remoteID, kind: .object, description: "group-owned") ), binding: binding, - modelContext: context, - isolation: MainActor.shared + groupID: groupID ) - #expect(evaluation.object === consoleObject) - #expect(consoleObject.description == "client") - - store.removeConsoleOwnership(from: [consoleObject], isolation: MainActor.shared) - #expect(try store.objectBinding(for: consoleObject, isolation: MainActor.shared)?.remoteID == objectID) + #expect(replacement !== oldObject) + store.removeConsoleOwnership(from: [oldObject]) + #expect(try store.objectBinding(for: replacement, groupID: groupID)?.remoteID == remoteID) } @MainActor @Test -func runtimeStateStoreTargetClearRemovesObjectsWithoutExecutionContexts() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) +func runtimeStateStoreNeverReusesSyntheticOrGroupIdentityAfterReset() { let store = RuntimeStateStore() - let pageTargetID = WebInspectorTarget.ID("page") - let frameTargetID = WebInspectorTarget.ID("frame") - let pageObject = store.registerConsoleParameter( - Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("page-object", scopedToTargetRawValue: pageTargetID.rawValue), - kind: .object - ), - modelContext: context, - isolation: MainActor.shared - ) - let frameObject = store.registerConsoleParameter( - Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("frame-object", scopedToTargetRawValue: frameTargetID.rawValue), - kind: .object - ), - modelContext: context, - isolation: MainActor.shared + let firstGroup = store.createGroupID() + let first = store.registerConsoleParameter( + Runtime.RemoteObject(id: nil, kind: .number, value: .number(1)) ) - #expect(store.executionContexts.isEmpty) - store.apply( - .executionContextsCleared(target: frameTargetID), - sourceTargetID: pageTargetID, - isolation: MainActor.shared + 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(try store.objectBinding(for: pageObject, isolation: MainActor.shared) != nil) #expect(throws: WebInspectorProxyError.disconnected( - "RuntimeObject is not registered in this WebInspectorContext." + "Runtime evaluation target is no longer current in this WebInspectorModelContext." )) { - try store.objectBinding(for: frameObject, isolation: MainActor.shared) + try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: nil, kind: .undefined) + ), + binding: oldBinding, + groupID: replacementGroup + ) } } @MainActor @Test -func runtimeStateStoreTargetClearPreservesOtherTargetContexts() throws { +func runtimeStateStoreTargetClearPreservesOtherTargetState() throws { let store = RuntimeStateStore() let pageTargetID = WebInspectorTarget.ID("page") let frameTargetID = WebInspectorTarget.ID("frame") @@ -208,8 +203,7 @@ func runtimeStateStoreTargetClearPreservesOtherTargetContexts() throws { name: "Page", kind: .normal )), - sourceTargetID: pageTargetID, - isolation: MainActor.shared + sourceTargetID: pageTargetID ) store.apply( .executionContextCreated(Runtime.ExecutionContext( @@ -217,126 +211,56 @@ func runtimeStateStoreTargetClearPreservesOtherTargetContexts() throws { name: "Frame", kind: .normal )), - sourceTargetID: frameTargetID, - isolation: MainActor.shared + sourceTargetID: frameTargetID ) let pageContext = try #require(store.executionContexts.first) let frameContext = try #require(store.executionContexts.last) - store.select(frameContext, isolation: MainActor.shared) + 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(target: frameTargetID), - sourceTargetID: pageTargetID, - isolation: MainActor.shared + sourceTargetID: pageTargetID ) #expect(store.executionContexts.count == 1) #expect(store.executionContexts.first === pageContext) #expect(store.selectedContext === pageContext) -} - -@MainActor -@Test -func runtimeStateStoreOldPointerCannotRemoveSameRemoteIDReplacement() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = RuntimeStateStore() - let remoteID = Runtime.RemoteObject.ID("reused-remote-id") - let oldObject = store.registerConsoleParameter( - Runtime.RemoteObject(id: remoteID, kind: .object, description: "old"), - modelContext: context, - isolation: MainActor.shared - ) - - store.reset(isolation: MainActor.shared) - - let replacement = store.registerConsoleParameter( - Runtime.RemoteObject(id: remoteID, kind: .object, description: "replacement"), - modelContext: context, - isolation: MainActor.shared - ) - #expect(replacement !== oldObject) - store.removeConsoleOwnership(from: [oldObject], isolation: MainActor.shared) - - #expect(try store.objectBinding(for: replacement, isolation: MainActor.shared)?.remoteID == remoteID) + #expect(try store.objectBinding(for: pageObject, groupID: groupID) != nil) #expect(throws: WebInspectorProxyError.disconnected( - "RuntimeObject is not registered in this WebInspectorContext." + "RuntimeObject is not registered in this WebInspectorModelContext." )) { - try store.objectBinding(for: oldObject, isolation: MainActor.shared) + try store.objectBinding(for: frameObject, groupID: groupID) } } -@MainActor -@Test -func runtimeStateStoreNeverReusesSyntheticIdentityAfterReset() { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = RuntimeStateStore() - let first = store.registerConsoleParameter( - Runtime.RemoteObject(id: nil, kind: .number, value: .number(1)), - modelContext: context, - isolation: MainActor.shared - ) - - store.reset(isolation: MainActor.shared) - - let second = store.registerConsoleParameter( - Runtime.RemoteObject(id: nil, kind: .number, value: .number(2)), - modelContext: context, - isolation: MainActor.shared - ) - #expect(first.id != second.id) - #expect(first !== second) -} - -@MainActor -@Test -func runtimeStateStoreRejectsDefaultEvaluationReplyAfterFullReset() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let store = RuntimeStateStore() - let binding = try store.evaluationBinding(for: nil, isolation: MainActor.shared) - - store.reset(isolation: MainActor.shared) - - #expect(throws: WebInspectorProxyError.disconnected( - "Runtime evaluation target is no longer current in this WebInspectorContext." - )) { - try store.finishEvaluation( - Runtime.EvaluationResult(object: Runtime.RemoteObject(id: nil, kind: .undefined)), - binding: binding, - modelContext: context, - isolation: MainActor.shared - ) - } -} - -@MainActor -@Test -func runtimeStateStoreDoesNotRetainRuntimeObjectModelContext() { - let store = RuntimeStateStore() - weak var releasedContext: WebInspectorContext? - let object: RuntimeObject - - do { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - releasedContext = context - object = store.registerConsoleParameter( - Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("weak-model-context"), - kind: .object - ), - modelContext: context, - isolation: MainActor.shared - ) - #expect(object.modelContext === context) - } - - #expect(releasedContext == nil) - #expect(object.modelContext == nil) -} - @MainActor @Test func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) let store = RuntimeStateStore() let contextID = Runtime.ExecutionContext.ID("reused-context") store.apply( @@ -345,16 +269,14 @@ func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() th name: "Before", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) let original = try #require(store.executionContexts.first) - let binding = try store.evaluationBinding(for: original, isolation: MainActor.shared) + let binding = try store.evaluationBinding(for: original) store.apply( .executionContextDestroyed(contextID), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) store.apply( .executionContextCreated(Runtime.ExecutionContext( @@ -362,19 +284,20 @@ func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() th name: "After", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page"), - isolation: MainActor.shared + sourceTargetID: WebInspectorTarget.ID("page") ) + let groupID = store.createGroupID() #expect(store.executionContexts.first !== original) #expect(throws: WebInspectorProxyError.disconnected( - "RuntimeContext is not registered in this WebInspectorContext." + "RuntimeContext is not registered in this WebInspectorModelContext." )) { try store.finishEvaluation( - Runtime.EvaluationResult(object: Runtime.RemoteObject(id: nil, kind: .undefined)), + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: nil, kind: .undefined) + ), binding: binding, - modelContext: context, - isolation: MainActor.shared + groupID: groupID ) } } diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 1da882c0..b3b893c4 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -3,8172 +3,688 @@ import Testing @testable import WebInspectorDataKit import WebInspectorProxyKit import WebInspectorProxyKitTesting -import WebInspectorTestSupport -@MainActor -@Test -func domEventsPopulateRootAndPreserveChildIdentity() async throws { - try await withDataKitTestRuntime { runtime in - 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") - - try await enqueueStartupReplies( - on: runtime.wire, - 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 } - - try await runtime.wire.emitRaw( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - attributes: ["class": "before"], - childNodeCount: 0 - ) - ]), - target: target - ) - let child = try await waitForChild(in: context) - #expect(context.node(for: child.id) === child) - - try await runtime.wire.emitRaw( - .attributeModified(childID, name: "class", value: "after"), - target: target - ) - try await waitUntil { child.attributes["class"] == "after" } - #expect(context.node(for: child.id) === child) - - try await runtime.wire.emitRaw( - .childNodeCountUpdated(childID, count: 2), - target: target - ) - try await waitUntil { child.childNodeCount == 2 } - #expect(context.node(for: child.id) === child) +private actor ModelContextActorProbe { + private let context = WebInspectorModelContext( + configuration: .init(domains: []) + ) - try await runtime.wire.emitRaw( - .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) - } - guard case let .loaded(grandchildren) = child.children else { - Issue.record("Expected loaded child subtree.") - return - } - let grandchild = try #require(grandchildren.first) - context.select(grandchild) + func attach(to proxy: WebInspectorProxy) async throws -> WebInspectorModelContext.State { + try await context.attach(to: proxy, isolation: self) + return context.state + } - try await runtime.wire.emitRaw( - .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 - } - #expect(context.node(for: child.id) == nil) - #expect(context.node(for: grandchild.id) == nil) - #expect(context.selectedNode == nil) + func close() async -> WebInspectorModelContext.State { + await context.close() + return context.state } } @MainActor @Test -func requestChildrenDispatchesDOMCommandAndMaterializesSetChildNodes() async throws { +func modelContextInheritsACustomCallerActorAndDoesNotRetainIt() async throws { try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let childID = DOM.Node.ID("requested-child") - - await runtime.wire.respond(to: "DOM.requestChildNodes") - - await document.requestChildren(depth: 2) + var probe: ModelContextActorProbe? = ModelContextActorProbe() + weak let releasedProbe = probe - let commands = runtime.wire.observations.commands - let command = try #require(commands.first { - $0.method == "DOM.requestChildNodes" - }) - #expect(try commandStringParameter(command, "nodeId") == document.id.proxyID.rawValue) - #expect(try commandIntegerParameter(command, "depth") == 2) + let attachedState = try await probe?.attach(to: runtime.proxy) + #expect(attachedState == .attached) + #expect(await probe?.close() == .closed) - try await runtime.wire.emitRaw( - .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 - } - return children.first?.id == DOMNode.ID(childID) + probe = nil + for _ in 0..<100 where releasedProbe != nil { + await Task.yield() } + #expect(releasedProbe == nil) } } @MainActor @Test -func domTreeSnapshotBuildsSelectorAndXPathFromDataKitProjection() async throws { - try await withDataKitTestRuntime { runtime in - 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"))) } -} - -@MainActor -@Test -func domCommandsDispatchThroughDataKitContext() async throws { - try await withDataKitTestRuntime { runtime in - 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, - 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: 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")] - ) - ] - ) - ] - ) - ] - ) - ] - ) - 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.wire.respond( - to: "DOM.getOuterHTML", - with: try rawOuterHTMLResult("") - ) - #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.wire.respond(to: "DOM.highlightNode") - try await context.highlightNode(for: child.id) - - await runtime.wire.respond(to: "DOM.hideHighlight") - try await context.hideHighlight() - - await runtime.wire.respond(to: "DOM.undo") - try await context.undoDOMChange() - - await runtime.wire.respond(to: "DOM.redo") - try await context.redoDOMChange() - - await runtime.wire.respond(to: "DOM.setInspectModeEnabled") - try await context.setElementPickerEnabled(true) - #expect(context.isElementPickerEnabled) - - try await enqueueCSSStyleReplies(on: runtime.wire) - await runtime.wire.respond(to: "DOM.highlightNode") - try await runtime.wire.emitRaw(.inspect(childID), target: target) - try await waitUntil { context.selectedNode === child } - try await waitUntil { child.elementStyles?.phase == .loaded } - #expect(context.isElementPickerEnabled == false) - - await runtime.wire.respond(to: "DOM.setAttributeValue") - await runtime.wire.respond(to: "DOM.markUndoableState") - try await context.setDOMAttribute( - "class", - value: "updated", - on: parent.id, - options: .automatic - ) - - await runtime.wire.respond(to: "DOM.setOuterHTML") - await runtime.wire.respond(to: "DOM.markUndoableState") - try await context.setDOMOuterHTML( - "", - of: child.id, - options: .automatic - ) - - await runtime.wire.respond(to: "DOM.removeNode") - await runtime.wire.respond(to: "DOM.removeNode") - await runtime.wire.respond(to: "DOM.markUndoableState") - await runtime.wire.respond(to: "DOM.markUndoableState") - let deletion = try await context.removeDOMNodes( - [parent.id, child.id], - options: .automatic - ) - #expect(deletion.acceptedNodeIDs == [child.id, parent.id]) - #expect(context.selectedNode == nil) - #expect(child.elementStyles == nil) - - await runtime.wire.respond(to: "Page.reload") - try await context.reloadPage(ignoringCache: true) - - let commands = runtime.wire.observations.commands - let outerHTML = try #require(commands.first { $0.method == "DOM.getOuterHTML" }) - #expect(try commandStringParameter(outerHTML, "nodeId") == childID.rawValue) - - let highlight = try #require(commands.first { $0.method == "DOM.highlightNode" }) - #expect(try commandStringParameter(highlight, "nodeId") == childID.rawValue) - - #expect(commands.contains { $0.method == "DOM.undo" }) - #expect(commands.contains { $0.method == "DOM.redo" }) - - let inspectMode = try #require(commands.first { $0.method == "DOM.setInspectModeEnabled" }) - #expect(try commandBooleanParameter(inspectMode, "enabled")) - - let setAttribute = try #require(commands.first { $0.method == "DOM.setAttributeValue" }) - #expect(try commandStringParameter(setAttribute, "nodeId") == parentID.rawValue) - #expect(try commandStringParameter(setAttribute, "name") == "class") - #expect(try commandStringParameter(setAttribute, "value") == "updated") - - let setOuterHTML = try #require(commands.first { $0.method == "DOM.setOuterHTML" }) - #expect(try commandStringParameter(setOuterHTML, "nodeId") == childID.rawValue) - #expect(try commandStringParameter(setOuterHTML, "outerHTML") == "") - - let removals = commands.filter { $0.method == "DOM.removeNode" } - #expect(removals.count == 2) - let firstRemoval = try #require(removals.first) - let lastRemoval = try #require(removals.last) - #expect(try commandStringParameter(firstRemoval, "nodeId") == childID.rawValue) - #expect(try commandStringParameter(lastRemoval, "nodeId") == parentID.rawValue) - let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } - #expect(undoMarks.count == 4) - - let reload = try #require(commands.first { $0.method == "Page.reload" }) - #expect(try commandBooleanParameter(reload, "ignoreCache")) + await #expect(throws: WebInspectorModelError.domainNotConfigured(.console)) { + _ = try await context.consoleMessages() } } @MainActor @Test -func domMutationsAndUndoRedoUseOwningFrameTarget() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let frameTarget = try await createFrameTarget(in: runtime) - let document = try #require(context.rootNode) - let scopedNodeID = DOM.Node.ID( - "frame-owned-node", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: scopedNodeID, nodeType: 3, nodeName: "#text", nodeValue: "frame") - ]), - target: target +func attachmentPublishesDOMSnapshotAndAcceptsFilteredSequenceGaps() async throws { + let documentID = DOM.Node.ID("document") + let childID = DOM.Node.ID("child") + let document = DOM.Node( + id: documentID, + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: childID, + nodeType: 1, + nodeName: "DIV", + localName: "div" + ) + ] + ) + 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 ) - try await waitUntil { context.node(for: DOMNode.ID(scopedNodeID)) != nil } - - await runtime.wire.respond(to: "DOM.setAttributeValue") - await runtime.wire.respond(to: "DOM.markUndoableState") - try await context.setDOMAttribute( - "data-edited", - value: "page", - on: document.id, - options: .automatic + // 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 ) - - await runtime.wire.respond(to: "DOM.removeNode") - await runtime.wire.respond(to: "DOM.markUndoableState") - _ = try await context.removeDOMNodes( - [DOMNode.ID(scopedNodeID)], - options: .automatic + 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 ) - await runtime.wire.respond(to: "DOM.undo") - try await context.undoDOMChange() - - await runtime.wire.respond(to: "DOM.redo") - try await context.redoDOMChange() - - let domCommands = runtime.wire.observations.commands - .filter { - [ - "DOM.setAttributeValue", - "DOM.removeNode", - "DOM.markUndoableState", - "DOM.undo", - "DOM.redo", - ].contains($0.method) - } - #expect(domCommands.map(\.method) == [ - "DOM.setAttributeValue", - "DOM.markUndoableState", - "DOM.removeNode", - "DOM.markUndoableState", - "DOM.undo", - "DOM.redo", - ]) - #expect(domCommands.prefix(2).allSatisfy { $0.destination == .target(wireTargetID(target)) }) - let frameCommands = domCommands.dropFirst(2) - #expect(frameCommands.allSatisfy { $0.destination == .target(wireTargetID(frameTarget)) }) - let removal = try #require(frameCommands.first { $0.method == "DOM.removeNode" }) - #expect(try commandStringParameter(removal, "nodeId") == scopedNodeID.unscopedRawValue) + try await waitUntil { + try fixture.context.networkRequest( + id: NetworkRequest.ID(requestID) + )?.status == 204 + } + #expect(fixture.context.state == .attached) } } @MainActor @Test -func domDeleteRejectsCrossTargetSelectionBeforeRemovingNodes() async throws { +func closeClearsAttachmentOwnedModelsAndBecomesTerminal() async throws { try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let frameTarget = try await createFrameTarget(in: runtime) - let document = try #require(context.rootNode) - let pageNodeID = DOM.Node.ID("page-node") - let scopedFrameNodeID = DOM.Node.ID( - "frame-node", - scopedToTargetRawValue: frameTarget.id.rawValue + let configuration = WebInspectorModelContext.Configuration(domains: [.network]) + let fixture = try await attachModelContext( + runtime: runtime, + configuration: configuration ) - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: pageNodeID, nodeType: 1, nodeName: "DIV", localName: "div"), - DOM.Node(id: scopedFrameNodeID, nodeType: 1, nodeName: "SPAN", localName: "span"), - ]), - target: target + let requestID = Network.Request.ID("close-request") + try await emitFinishedRequest( + id: requestID, + target: fixture.target, + wire: runtime.wire ) try await waitUntil { - context.node(for: DOMNode.ID(pageNodeID)) != nil - && context.node(for: DOMNode.ID(scopedFrameNodeID)) != nil - } - - await #expect(throws: WebInspectorProxyError.commandFailed( - domain: "DOM", - method: "removeNode", - message: "Deleting nodes from multiple DOM targets in one mutation is not supported." - )) { - _ = try await context.removeDOMNodes([ - DOMNode.ID(pageNodeID), - DOMNode.ID(scopedFrameNodeID), - ], options: .automatic) + try fixture.context.networkRequest(id: NetworkRequest.ID(requestID)) != nil } - let removeCommands = runtime.wire.observations.commands - .filter { $0.method == "DOM.removeNode" } - #expect(removeCommands.isEmpty) - } -} - -@MainActor -@Test -func domInspectSelectsKnownNodeAndLoadsStyles() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("inspect-node") - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target + await enqueueShutdownReplies( + on: runtime.wire, + configuration: configuration ) - let element = try await waitForChild(in: context) + await fixture.context.close() - try await enqueueCSSStyleReplies(on: runtime.wire) - await runtime.wire.respond(to: "DOM.highlightNode") - try await runtime.wire.emitRaw(.inspect(elementID), target: target) - - try await waitUntil { context.selectedNode === element } - try await waitUntil { - runtime.wire.observations.commands.contains { command in - command.method == "DOM.highlightNode" - && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue - } + #expect(fixture.context.state == .closed) + #expect(try fixture.context.networkRequest(id: NetworkRequest.ID(requestID)) == nil) + await #expect(throws: WebInspectorModelContext.TransitionError.closed) { + try await fixture.context.attach(to: runtime.proxy, isolation: MainActor.shared) } - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(styles.sections.map(\.title) == [".card"]) - - let commands = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" }) - #expect(commands.contains { $0.method == "CSS.getComputedStyleForNode" }) } } @MainActor @Test -func domInspectWaitsForRequestNodePathBeforeSelectingUnresolvedNode() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let staleID = DOM.Node.ID("stale-node") - let elementID = DOM.Node.ID("resolved-inspect-node") - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: staleID, nodeType: 1, nodeName: "SPAN", localName: "span") - ]), - target: target +func concurrentResponseBodyCallersJoinOneProtocolRequest() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.network]) + ) { fixture in + let requestID = Network.Request.ID("joined-body") + try await emitFinishedRequest( + id: requestID, + target: fixture.target, + wire: fixture.runtime.wire + ) + let request = try await requireRequest( + requestID, + in: fixture.context + ) + let gate = await fixture.runtime.wire.deferReply( + to: "Network.getResponseBody", + with: try rawNetworkBodyResult( + Network.Body(data: "shared", base64Encoded: false) + ) ) - try await waitUntil { context.node(for: DOMNode.ID(staleID)) != nil } - - try await enqueueCSSStyleReplies(on: runtime.wire) - await runtime.wire.respond(to: "DOM.highlightNode") - try await runtime.wire.emitRaw(.inspect(elementID), target: target) - #expect(context.selectedNode == nil) - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target + let expectedBody = request.responseBody + let first = Task { + let body = try await fixture.context.responseBody(for: request) + return body === expectedBody + } + let second = Task { + let body = try await fixture.context.responseBody(for: request) + return body === expectedBody + } + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "Network.getResponseBody", + count: 1 ) + first.cancel() + gate.open() - try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } - try await waitUntil { - runtime.wire.observations.commands.contains { command in - command.method == "DOM.highlightNode" - && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue - } + await #expect(throws: CancellationError.self) { + try await first.value } - #expect(context.state == .attached) - #expect(runtime.wire.observations.commandMethods.contains( - "DOM.requestChildNodes" - ) == false) - #expect(context.node(for: DOMNode.ID(staleID)) == nil) - let selected = try #require(context.selectedNode) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } + let secondUsedSameBody = try await second.value + #expect(secondUsedSameBody) + #expect(expectedBody === request.responseBody) + #expect(expectedBody.phase == .loaded) + #expect(expectedBody.text == "shared") + #expect(fixture.runtime.wire.observations.commands.filter { + $0.method == "Network.getResponseBody" + }.count == 1) } } @MainActor @Test -func domInspectBeforeDocumentArrivesWaitsForRequestNodePathAfterRootApplies() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let elementID = DOM.Node.ID("deferred-inspect-node") - - let gate = await runtime.wire.deferReply( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") +func clearingNetworkInvalidatesEveryJoinedResponseBodyWaiter() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.network]) + ) { fixture in + let requestID = Network.Request.ID("cleared-body") + try await emitFinishedRequest( + id: requestID, + target: fixture.target, + wire: fixture.runtime.wire + ) + let request = try await requireRequest( + requestID, + in: fixture.context + ) + let body = request.responseBody + let gate = await fixture.runtime.wire.deferReply( + to: "Network.getResponseBody", + with: try rawNetworkBodyResult( + Network.Body(data: "late", base64Encoded: false) ) ) - await enqueueDomainEnableReplies(on: runtime.wire) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - runtime.wire.observations.commands - .contains { $0.method == "DOM.getDocument" } + let first = Task { + _ = try await fixture.context.responseBody(for: request) } - - try await runtime.wire.emitRaw(.inspect(elementID), target: target) - await runtime.wire.respond(to: "DOM.highlightNode") - try await enqueueCSSStyleReplies(on: runtime.wire) - gate.open() - - try await waitUntil { context.rootNode?.id == DOMNode.ID(documentID) } - #expect(context.selectedNode == nil) - - try await runtime.wire.emitRaw( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target + let second = Task { + _ = try await fixture.context.responseBody(for: request) + } + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "Network.getResponseBody", + count: 1 ) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(elementID) } - try await waitUntil { - runtime.wire.observations.commands.contains { command in - command.method == "DOM.highlightNode" - && (try? commandStringParameter(command, "nodeId")) == elementID.rawValue - } + await fixture.context.clearNetworkRequests() + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await first.value } - let selected = try #require(context.selectedNode) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(runtime.wire.observations.commandMethods.contains( - "DOM.requestChildNodes" - ) == false) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await second.value + } + guard case .failed(.staleIdentifier) = body.phase else { + Issue.record("Expected the cleared response body to become stale.") + return + } + #expect(try fixture.context.networkRequest(id: NetworkRequest.ID(requestID)) == nil) + gate.open() } } @MainActor @Test -func explicitSelectionSupersedesPendingDOMInspectResolution() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let selectedID = DOM.Node.ID("manual-selection") - let inspectedID = DOM.Node.ID("late-inspect-node") - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button") - ]), - target: target - ) - let manualSelection = try await waitForChild(in: context) - - context.apply(.inspect(inspectedID)) - - try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(manualSelection) - try await waitUntil { context.selectedNode === manualSelection } - - try await runtime.wire.emitRaw( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: selectedID, nodeType: 1, nodeName: "BUTTON", localName: "button"), - DOM.Node(id: inspectedID, nodeType: 1, nodeName: "DIV", localName: "div"), - ]), - target: target +func elementPickerSelectsResolvedNodeAndBalancesItsScopedLease() async throws { + let selectedID = DOM.Node.ID("42") + let document = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: selectedID, + nodeType: 1, + nodeName: "BUTTON", + localName: "button" + ) + ] + ) + try await withAttachedModelContext( + configuration: .init(domains: [.dom]), + document: document + ) { fixture in + await fixture.runtime.wire.respond(to: "Inspector.enable") + await fixture.runtime.wire.respond(to: "Inspector.initialized") + await fixture.runtime.wire.respond(to: "DOM.setInspectModeEnabled") + await fixture.runtime.wire.respond( + to: "DOM.requestNode", + with: try testJSONObject(#"{"nodeId":42}"#) + ) + await fixture.runtime.wire.respond(to: "DOM.setInspectModeEnabled") + await fixture.runtime.wire.respond(to: "Inspector.disable") + + try await fixture.context.setElementPickerEnabled(true) + #expect(try fixture.context.isElementPickerEnabled) + try await fixture.runtime.wire.emitTargetEvent( + targetID: wireTargetID(fixture.target), + method: "Inspector.inspect", + parameters: try testJSONObject( + #"{"object":{"objectId":"remote-node","type":"object","subtype":"node"},"hints":{}}"# + ) ) - try await waitUntil { context.node(for: DOMNode.ID(inspectedID)) != nil } - #expect(context.selectedNode === manualSelection) - #expect(runtime.wire.observations.commandMethods.contains( - "DOM.requestChildNodes" - ) == false) + try await waitUntil { + try fixture.context.selectedDOMNode?.id == DOMNode.ID(selectedID) + && fixture.context.isElementPickerEnabled == false + } + let methods = fixture.runtime.wire.observations.commandMethods + #expect(methods.containsSubsequence([ + "Inspector.enable", + "Inspector.initialized", + "DOM.setInspectModeEnabled", + "DOM.requestNode", + "DOM.setInspectModeEnabled", + "Inspector.disable", + ])) } } @MainActor @Test -func domMutationEventForUnmaterializedNodeIsSkipped() async throws { - // Live pages emit mutation events for nodes WebKit has bound for this - // frontend but this context has not materialized (attach mid-flight, - // evicted subtrees). They must be skipped, not fail the context. - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let childID = DOM.Node.ID("child") - - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document", childNodeCount: 1) +func explicitDOMSelectionSupersedesPendingPickerResolution() async throws { + let inspectedID = DOM.Node.ID("42") + let manualID = DOM.Node.ID("manual") + let document = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node(id: inspectedID, nodeType: 1, nodeName: "DIV", localName: "div"), + DOM.Node(id: manualID, nodeType: 1, nodeName: "BUTTON", localName: "button"), + ] + ) + try await withAttachedModelContext( + configuration: .init(domains: [.dom]), + document: document + ) { fixture in + await fixture.runtime.wire.respond(to: "Inspector.enable") + await fixture.runtime.wire.respond(to: "Inspector.initialized") + await fixture.runtime.wire.respond(to: "DOM.setInspectModeEnabled") + let requestNodeGate = await fixture.runtime.wire.deferReply( + to: "DOM.requestNode", + with: try testJSONObject(#"{"nodeId":42}"#) + ) + await fixture.runtime.wire.respond(to: "DOM.setInspectModeEnabled") + await fixture.runtime.wire.respond(to: "Inspector.disable") + + try await fixture.context.setElementPickerEnabled(true) + try await fixture.runtime.wire.emitTargetEvent( + targetID: wireTargetID(fixture.target), + method: "Inspector.inspect", + parameters: try testJSONObject( + #"{"object":{"objectId":"remote-node","type":"object","subtype":"node"},"hints":{}}"# + ) ) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - try await runtime.wire.emitRaw( - .attributeModified(DOM.Node.ID("unmaterialized"), name: "class", value: "x"), - target: target + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "DOM.requestNode", + count: 1 ) - try await runtime.wire.emitRaw( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 0) - ]), - target: target + let manual = try #require( + try fixture.context.domNode(id: DOMNode.ID(manualID)) ) + try fixture.context.selectDOMNode(manual) + requestNodeGate.open() - let child = try await waitForChild(in: context) - #expect(child.id == DOMNode.ID(childID)) - #expect(context.state == .attached) + try await waitUntil { + try fixture.context.isElementPickerEnabled == false + } + #expect(try fixture.context.selectedDOMNode === manual) } } @MainActor @Test -func startupEnablesTrackedDomainsBeforeInitialDocumentSnapshot() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - try await enqueueStartupReplies(on: runtime.wire) +func runtimeObjectGroupsUseUniqueWireNamesAndReleaseExactlyOnce() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.runtime]) + ) { fixture in + await fixture.runtime.wire.respond(to: "Runtime.releaseObjectGroup") + await fixture.runtime.wire.respond(to: "Runtime.releaseObjectGroup") - 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 fixture.context.withRuntimeObjectGroup(named: "first group") { group in + #expect(group.isClosed == false) + } + try await fixture.context.withRuntimeObjectGroup(named: "second group") { group in + #expect(group.isClosed == false) + } - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method).prefix(startupCommands.count) == startupCommands[...]) + let releases = fixture.runtime.wire.observations.commands.filter { + $0.method == "Runtime.releaseObjectGroup" + } + #expect(releases.count == 2) + let names = try releases.map { + try commandStringParameter($0, "objectGroup") + } + #expect(Set(names).count == 2) + #expect(names[0].contains("first_group")) + #expect(names[1].contains("second_group")) } } @MainActor @Test -func networkEnableFailureFailsStartupBeforeDocumentFetch() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Runtime.enable") - await runtime.wire.fail("Network.enable", message: "Network enable failed.") - await runtime.wire.respond(to: "Runtime.disable") - await runtime.wire.respond(to: "Inspector.disable") +func runtimeGroupCleanupFailureLeavesGroupRetryableAndItsObjectUsable() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.runtime]) + ) { fixture in + await fixture.runtime.wire.respond( + to: "Runtime.evaluate", + with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( + object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID("retry-object"), + kind: .object, + description: "object" + ) + )) + ) + await fixture.runtime.wire.fail( + "Runtime.releaseObjectGroup", + message: "transient release failure" + ) + var capturedGroup: RuntimeObjectGroup? + var capturedObject: RuntimeObject? - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false + await #expect(throws: WebInspectorProxyError.self) { + try await fixture.context.withRuntimeObjectGroup(named: "retry") { group in + capturedGroup = group + capturedObject = try await group.evaluate("({ value: 1 })").object } - return true } - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return - } - guard case .commandFailed(domain: "Network", method: "enable", message: _) = error else { - Issue.record("Expected Network.enable command failure.") - return - } + let group = try #require(capturedGroup) + let object = try #require(capturedObject) + #expect(group.isClosed == false) + await fixture.runtime.wire.respond( + to: "Runtime.getProperties", + with: try rawRuntimePropertiesResult([]) + ) + #expect(try await group.properties(of: object).isEmpty) - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Network.enable", - "Runtime.disable", - "Inspector.disable", - ]) - #expect(context.rootNode == nil) + await fixture.runtime.wire.respond(to: "Runtime.releaseObjectGroup") + try await group.close() + #expect(group.isClosed) + await #expect(throws: WebInspectorModelError.staleModel) { + _ = try await group.properties(of: object) + } + #expect(fixture.runtime.wire.observations.commands.filter { + $0.method == "Runtime.releaseObjectGroup" + }.count == 2) } } @MainActor @Test -func consoleEnableFailureFailsStartupBeforeAttachingDocument() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Runtime.enable") - await runtime.wire.respond(to: "Network.enable") - await runtime.wire.respond( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") - ) +func runtimeGroupPreservesOperationAndCleanupFailures() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.runtime]) + ) { fixture in + await fixture.runtime.wire.fail( + "Runtime.releaseObjectGroup", + message: "cleanup failed" ) - await runtime.wire.fail("Console.enable", message: "Console enable failed.") - await runtime.wire.respond(to: "Runtime.disable") - await runtime.wire.respond(to: "Network.disable") - await runtime.wire.respond(to: "Inspector.disable") - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false + do { + try await fixture.context.withRuntimeObjectGroup { _ in + throw ModelContextTestFailure.operation } - return true - } - - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return - } - guard case .commandFailed(domain: "Console", method: "enable", message: _) = error else { - Issue.record("Expected Console.enable command failure.") - return + Issue.record("Expected both Runtime scope failures.") + } catch let error as WebInspectorRuntimeScopeError { + #expect(error.operationError is ModelContextTestFailure) + #expect(error.cleanupError is WebInspectorProxyError) } - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Network.enable", - "DOM.getDocument", - "Console.enable", - "Runtime.disable", - "Network.disable", - "Inspector.disable", - ]) - #expect(context.rootNode == nil) } } @MainActor @Test -func runtimeEnableFailureFailsStartupBeforeConsoleNetworkAndDocumentFetch() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() +func runtimeGroupCleansUpWhenTheOperationIsCancelled() async throws { + try await withAttachedModelContext( + configuration: .init(domains: [.runtime]) + ) { fixture in + await fixture.runtime.wire.respond(to: "Runtime.releaseObjectGroup") - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.fail("Runtime.enable", message: "Runtime enable failed.") - await runtime.wire.respond(to: "Inspector.disable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - guard case .failed = context.state else { - return false + await #expect(throws: CancellationError.self) { + try await fixture.context.withRuntimeObjectGroup { _ in + throw CancellationError() } - return true - } - - guard case let .failed(error) = context.state else { - Issue.record("Expected failed context state.") - return - } - guard case .commandFailed(domain: "Runtime", method: "enable", message: _) = error else { - Issue.record("Expected Runtime.enable command failure.") - return } - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Inspector.disable", - ]) - #expect(context.rootNode == nil) + #expect(fixture.runtime.wire.observations.commands.filter { + $0.method == "Runtime.releaseObjectGroup" + }.count == 1) } } -@MainActor -@Test -func closeAfterAttachedDisablesEnabledDomains() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - try await enqueueStartupReplies(on: runtime.wire) - await enqueueDomainDisableReplies(on: runtime.wire) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } - - await container.close() - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == startupCommands + shutdownCommands) - #expect(context.state == .detached) - #expect(context.teardownError == nil) - } +private struct AttachedModelFixture { + let runtime: DataKitTestRuntime + let target: WebInspectorTarget + let context: WebInspectorModelContext + let configuration: WebInspectorModelContext.Configuration } @MainActor -@Test -func closeAfterAttachedClearsAttachmentBackedModels() async throws { +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 target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let requestID = Network.Request.ID("request-1") - let runtimeContextID = Runtime.ExecutionContext.ID("main") - let networkResults: WebInspectorFetchedResults - let consoleResults: WebInspectorFetchedResults - - try await enqueueStartupReplies( + 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, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") + configuration: configuration ) + await fixture.context.close() + return try result.get() + } +} - 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 } +@MainActor +private func attachModelContext( + runtime: DataKitTestRuntime, + configuration: WebInspectorModelContext.Configuration, + document: DOM.Node = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document" + ) +) async throws -> AttachedModelFixture { + let target = try await runtime.proxy.waitForCurrentPage() + await enqueueStartupReplies( + on: runtime.wire, + configuration: configuration, + document: document + ) + 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 runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "hello", - timestamp: 2 - )), - target: target - ) - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext(id: runtimeContextID, name: "Main", kind: .normal)), - target: target - ) - 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.wire) - 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) +private func enqueueStartupReplies( + on wire: DataKitRawWireDriver, + configuration: WebInspectorModelContext.Configuration, + document: DOM.Node +) async { + if configuration.domains.contains(.css) { + await wire.respond(to: "CSS.enable") } -} - -@MainActor -@Test -func closeRecordsNetworkDisableFailureAndDetachesContext() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - try await enqueueStartupReplies(on: runtime.wire) - await runtime.wire.respond(to: "Console.disable") - await runtime.wire.respond(to: "Runtime.disable") - await runtime.wire.fail("Network.disable", message: "Network disable failed.") - await runtime.wire.respond(to: "Inspector.disable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } - - await container.close() - - #expect(context.state == .detached) - guard case .commandFailed(domain: "Network", method: "disable", message: _) = context.teardownError else { - Issue.record("Expected Network.disable teardown error.") - return - } + if configuration.domains.contains(.network) { + await wire.respond(to: "Network.enable") } -} - -@MainActor -@Test -func restartDisablesPreviousDomainTrackingBeforeReenable() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("document-1"), 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?.id == DOMNode.ID(DOM.Node.ID("document-1")) } - - await enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("document-2"), nodeType: 9, nodeName: "#document") - ) - - context.start() - - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("document-2")) } - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == startupCommands + shutdownCommands + startupCommands) + if configuration.domains.contains(.console) { + await wire.respond(to: "Console.enable") } -} - -@MainActor -@Test -func restartWaitsForPreviousStartupCleanupBeforeReenable() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = await runtime.wire.deferReply(to: "Network.enable") - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Runtime.enable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Network.enable", - ] - } - - await enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) - - context.start() - - enableGate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == Array(startupCommands.prefix(4)) + [ - "Runtime.disable", - "Network.disable", - "Inspector.disable", - ] + startupCommands) + if configuration.domains.contains(.runtime) { + await wire.respond(to: "Runtime.enable") } -} - -@MainActor -@Test -func runtimeEnableReplayIsCapturedBeforeCommandReturns() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let contextID = Runtime.ExecutionContext.ID("main") - - let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Network.enable") - await runtime.wire.respond( + if configuration.domains.contains(.dom) { + await wire.respond( to: "DOM.getDocument", - with: try emptyDocumentResult() - ) - await runtime.wire.respond(to: "Console.enable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable" - ] - } - - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext(id: contextID, name: "Main", kind: .normal)), - target: target + with: try! domDocumentResult(document) ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(contextID) - } - - enableGate.open() - try await waitUntil { context.state == .attached } - #expect(context.selectedContext?.id == RuntimeContext.ID(contextID)) } } -@MainActor -@Test -func consoleEnableReplayIsCapturedBeforeCommandReturns() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = await runtime.wire.deferReply(to: "Console.enable") - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Runtime.enable") - await runtime.wire.respond(to: "Network.enable") - await runtime.wire.respond( - to: "DOM.getDocument", - with: try emptyDocumentResult() - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - runtime.wire.observations.commandMethods == startupCommands - } - - try await runtime.wire.emitRaw( - .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"] } - - enableGate.open() - try await waitUntil { context.state == .attached } - #expect(results.items.map(\.text) == ["replayed"]) +private func enqueueShutdownReplies( + on wire: DataKitRawWireDriver, + configuration: WebInspectorModelContext.Configuration +) async { + if configuration.domains.contains(.runtime) { + await wire.respond(to: "Runtime.disable") } -} - -@MainActor -@Test -func startupRefetchesDocumentWhenMainFrameNavigatesBeforeAttach() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let staleDocumentID = DOM.Node.ID("stale-startup-document") - let freshDocumentID = DOM.Node.ID("fresh-startup-document") - - let consoleGate = await runtime.wire.deferReply(to: "Console.enable") - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - await runtime.wire.respond(to: "Runtime.enable") - await runtime.wire.respond(to: "Network.enable") - await runtime.wire.respond( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: staleDocumentID, nodeType: 9, nodeName: "#document") - ) - ) - await runtime.wire.respond( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: freshDocumentID, nodeType: 9, nodeName: "#document") - ) - ) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitUntil { - runtime.wire.observations.commandMethods == startupCommands - } - - try await runtime.wire.emitRaw( - .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 - ) - consoleGate.open() - - try await waitUntil { context.rootNode?.id == DOMNode.ID(freshDocumentID) } - #expect(context.state == .attached) - #expect(context.node(for: DOMNode.ID(staleDocumentID)) == nil) - #expect(runtime.wire.observations.commandMethods == startupCommands + [ - "DOM.getDocument" - ]) + if configuration.domains.contains(.console) { + await wire.respond(to: "Console.disable") } -} - -@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"}}"# - ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(Runtime.ExecutionContext.ID("11")) + if configuration.domains.contains(.network) { + await wire.respond(to: "Network.disable") } - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" - ) - - let networkEnable = try await waitForTransportTargetMessage(backend, method: "Network.enable") - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" - ) - - 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 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}}"# - ) - try await waitUntil { consoleResults.items.map(\.text) == ["replayed"] } - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" - ) - - 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"]) -} - -@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" - ) - let startupMessageCount = await backend.sentTargetMessages().count - - let enablePickerTask = Task { @MainActor in - try await context.setElementPickerEnabled(true) + if configuration.domains.contains(.css) { + await wire.respond(to: "CSS.disable") } - let inspectMode = try await waitForTransportTargetMessage( - backend, - method: "DOM.setInspectModeEnabled", - after: startupMessageCount - ) - #expect(inspectMode.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: inspectMode.targetIdentifier, - messageID: try transportMessageID(inspectMode.message), - result: "{}" - ) - 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 requestNode = try await waitForTransportTargetMessage( - backend, - method: "DOM.requestNode", - after: startupMessageCount - ) - #expect(requestNode.targetIdentifier == targetID) - #expect(try transportTargetMessageParameters(requestNode.message)["objectId"] as? String == "node-object") - - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "DOM.setChildNodes", - params: ##"{"parentId":"1","nodes":[{"nodeId":42,"nodeType":1,"nodeName":"DIV","localName":"div","nodeValue":"","childNodeCount":0}]}"## - ) - await receiveTransportTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try transportMessageID(requestNode.message), - result: #"{"nodeId":42}"# - ) - - try await waitUntil { context.selectedNode?.id == DOMNode.ID(inspectedID) } - #expect(context.isElementPickerEnabled == false) - - 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: "{}" - ) - - let matchedStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getMatchedStylesForNode", - after: startupMessageCount - ) - #expect(matchedStyles.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: matchedStyles.targetIdentifier, - messageID: try transportMessageID(matchedStyles.message), - result: #"{"matchedCSSRules":[],"inherited":[],"pseudoElements":[]}"# - ) - - 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 computedStyle = try await waitForTransportTargetMessage( - backend, - method: "CSS.getComputedStyleForNode", - after: startupMessageCount - ) - #expect(computedStyle.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: computedStyle.targetIdentifier, - messageID: try transportMessageID(computedStyle.message), - result: #"{"computedStyle":[]}"# - ) - - 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 - - let enablePickerTask = Task { @MainActor in - try await context.setElementPickerEnabled(true) - } - let inspectMode = try await waitForTransportTargetMessage( - backend, - method: "DOM.setInspectModeEnabled", - after: startupMessageCount - ) - await receiveTransportTargetReply( - transport, - targetID: inspectMode.targetIdentifier, - messageID: try transportMessageID(inspectMode.message), - result: "{}" - ) - 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}]}"## - ) - await receiveTransportTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try transportMessageID(requestNode.message), - result: #"{"nodeId":42}"# - ) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(inspectedID) } - - let highlight = try await waitForTransportTargetMessage( - backend, - method: "DOM.highlightNode", - after: startupMessageCount - ) - await receiveTransportTargetReply( - transport, - targetID: highlight.targetIdentifier, - messageID: try transportMessageID(highlight.message), - result: "{}" - ) - - 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 hideHighlight = try await waitForTransportTargetMessage( - backend, - method: "DOM.hideHighlight", - after: beforeNavigationMessageCount - ) - #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 +private func emitFinishedRequest( + id: Network.Request.ID, + target: WebInspectorTarget, + wire: DataKitRawWireDriver +) async throws { + try await wire.emitRaw( + .requestWillBeSent( + id: id, + request: Network.Request( + id: id, + url: "https://example.com/\(id.rawValue)", + method: "GET" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: target ) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "2") + try await wire.emitRaw( + .responseReceived( + id: id, + response: Network.Response( + url: "https://example.com/\(id.rawValue)", + status: 200, + mimeType: "text/plain" + ), + resourceType: .fetch, + timestamp: 2 + ), + target: target + ) + try await wire.emitRaw( + .loadingFinished( + id: id, + timestamp: 3, + sourceMapURL: nil, + metrics: nil + ), + target: target ) - - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("2")) } - #expect(context.selectedNode == nil) } @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" - ) - 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}}}"# - ) - await receiveTransportTargetEvent( - transport, - targetID: frameTargetID, - method: "Runtime.executionContextCreated", - params: #"{"context":{"id":7,"name":"Frame","frameId":"frame-runtime","type":"normal"}}"# - ) +private func requireRequest( + _ id: Network.Request.ID, + in context: WebInspectorModelContext +) async throws -> NetworkRequest { try await waitUntil { - context.executionContexts.contains { $0.id == RuntimeContext.ID(scopedContextID) } + try context.networkRequest(id: NetworkRequest.ID(id))?.state == .finished } - let frameContext = try #require(context.executionContexts.first { $0.id == RuntimeContext.ID(scopedContextID) }) - - var capturedEvaluation: RuntimeEvaluation? - let evaluationTask = Task { @MainActor in - capturedEvaluation = try await context.evaluate("window", in: frameContext) - } - let evaluate = try await waitForTransportTargetMessage( - backend, - method: "Runtime.evaluate", - after: startupMessageCount - ) - #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"}}"# - ) - 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}}"# + return try #require( + try context.networkRequest(id: NetworkRequest.ID(id)) ) - 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") } @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" - ) - 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}}}"# - ) - - let editTask = Task { @MainActor in - try await context.setCSSStyleSheetText( - "body { color: red; }", - for: styleSheetID, - options: .automatic - ) +private func waitUntil( + _ condition: @MainActor () throws -> Bool +) async throws { + for _ in 0..<2_000 { + if try condition() { + return + } + await Task.yield() } - let setStyleSheetText = try await waitForTransportTargetMessage( - backend, - method: "CSS.setStyleSheetText", - after: startupMessageCount - ) - #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: "{}" - ) - - let markUndoableState = try await waitForTransportTargetMessage( - backend, - method: "DOM.markUndoableState", - after: startupMessageCount - ) - #expect(markUndoableState.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: markUndoableState.targetIdentifier, - messageID: try transportMessageID(markUndoableState.message), - result: "{}" - ) - try await editTask.value + throw ModelContextTestFailure.timedOut } -@MainActor -@Test -func currentPageCommitClearsOldLifecycleStateBeforeAcceptingNewPageEvents() async throws { - try await withDataKitTestRuntime { runtime in - let (_, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let concreteResults = try await context.consoleMessages() - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "old page" - )), - targetID: .currentPage - ) - try await waitUntil { results.items.map(\.text) == ["old page"] } - #expect(concreteResults.items.map(\.text) == ["old page"]) - - context.apply(.didCommitProvisionalTarget(WebInspectorTargetCommitLifecycle( - oldTargetID: .currentPage, - newTarget: WebInspectorLifecycleTarget( - id: .currentPage, - kind: .page, - frameID: FrameID("committed-main-frame"), - isProvisional: false, - pageBindingID: "committed-page" - ) - ))) - - #expect(results.items.isEmpty) - #expect(concreteResults.items.isEmpty) - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "new page" - )), - targetID: .currentPage - ) - #expect(results.items.map(\.text) == ["new page"]) - #expect(concreteResults.items.map(\.text) == ["new page"]) - } +private enum ModelContextTestFailure: Error { + case operation + case timedOut } -@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)] - } - - 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"}}"# - ) - 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"}}}"# - ) - - 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 +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) } - 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 concreteResults = try await context.consoleMessages() - await context.apply( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "destroyed page" - )), - targetID: .currentPage - ) - #expect(legacyResults.items.map(\.text) == ["destroyed page"]) - #expect(concreteResults.items.map(\.text) == ["destroyed page"]) - - context.apply(.targetDestroyed(targetID: .currentPage)) - - #expect(context.rootNode === oldRoot) - #expect(legacyResults.items.isEmpty) - #expect(concreteResults.items.isEmpty) - } -} - -@MainActor -@Test -func currentPageTargetDestroyedDuringRetargetDoesNotDetachOrClearNetwork() async throws { - let oldTargetID = ProtocolTarget.ID("page-old") - let newTargetID = ProtocolTarget.ID("page-new-after-destroy") - let retainedRequestID = Network.Request.ID("destroy-retained-request") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: oldTargetID, - documentID: "destroyed-root" - ) - let networkResults: WebInspectorFetchedResults = 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let networkResults: WebInspectorFetchedResults = context.fetchedResults() - let staleRequestID = Network.Request.ID("stale-before-restart") - - try await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.wire) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] - } - let concreteNetworkResults = try await context.networkRequests() - #expect(concreteNetworkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)]) - - await enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("fresh-after-restart"), nodeType: 9, nodeName: "#document") - ) - - context.start() - - #expect(networkResults.items.isEmpty) - #expect(concreteNetworkResults.items.isEmpty) - #expect(context.registeredRequest(for: NetworkRequest.ID(staleRequestID)) == nil) - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let staleID = Runtime.ExecutionContext.ID("stale") - let replayID = Runtime.ExecutionContext.ID("replayed") - let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") - - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext(id: staleID, name: "Stale", kind: .normal)), - target: target - ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(staleID) - } - - await enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) - - context.start() - try await waitUntil { - runtime.wire.observations.commandMethods == startupCommands + shutdownCommands + [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - ] - } - #expect(context.executionContexts.isEmpty) - #expect(context.selectedContext == nil) - - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext(id: replayID, name: "Replayed", kind: .normal)), - target: target - ) - try await waitUntil { - context.executionContexts.map(\.id) == [RuntimeContext.ID(replayID)] - } - - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let enableGate = await runtime.wire.deferReply(to: "Console.enable") - - try await runtime.wire.emitRaw( - .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 enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) - - context.start() - try await waitUntil { - runtime.wire.observations.commandMethods == startupCommands + shutdownCommands + startupCommands - } - #expect(results.items.isEmpty) - - try await runtime.wire.emitRaw( - .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"] } - - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - await runtime.wire.respond(to: "DOM.setAttributeValue") - await runtime.wire.respond(to: "DOM.markUndoableState") - try await context.setDOMAttribute( - "data-before-reset", - value: "1", - on: document.id, - options: .automatic - ) - 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.wire.respond( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: replacementID, nodeType: 9, nodeName: "#document") - ) - ) - - try await runtime.wire.emitRaw(.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.wire.respond(to: "DOM.undo") - await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { - try await context.undoDOMChange() - } - - let commands = runtime.wire.observations.commands - #expect(!commands.contains { $0.method == "DOM.undo" }) - } -} - -@MainActor -@Test -func childInsertIntoUnrequestedParentDoesNotMarkChildrenLoaded() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let insertedID = DOM.Node.ID("inserted-child") - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - try await runtime.wire.emitRaw( - .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))) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await runtime.wire.emitRaw( - .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))) - - try await runtime.wire.emitRaw( - .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)] - ))) - - try await runtime.wire.emitRaw( - .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)) - - try await runtime.wire.emitRaw( - .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)] - ) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .childNodeCountUpdated(elementID, count: 1), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(elementID))?.childNodeCount == 1 } - try await recorder.waitForUpdateCount(4) - - try await runtime.wire.emitRaw( - .setChildNodes(parent: elementID, nodes: [ - DOM.Node(id: textID, nodeType: 3, nodeName: "#text", nodeValue: "old") - ]), - target: target - ) - try await recorder.waitForUpdateCount(5) - - try await runtime.wire.emitRaw( - .characterDataModified(textID, value: "new"), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(textID))?.nodeValue == "new" } - try await recorder.waitForUpdateCount(6) - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await runtime.wire.emitRaw( - .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]) - - try await enqueueCSSStyleReplies(on: runtime.wire) - try context.selectNode(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.selectNode(document.id, reveal: .none) - try await recorder.waitForUpdateCount(4) - #expect(revealRecorder.requests.count == 1) - } -} - -@MainActor -@Test -func setChildNodesReplacementPublishesSelectionClearingForRemovedDescendant() async throws { - try await withDataKitTestRuntime { runtime in - 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") - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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 fetchedResultsUpdatesStartWithExactCurrentState() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let requestID = seedFetchedResultsRequest( - "initial-publication", - url: "https://example.com/initial", - timestamp: 1, - in: context - ) - let results: WebInspectorFetchedResults = context.fetchedResults() - var iterator = results.updates().makeAsyncIterator() - - guard case let .initial(revision, snapshot)? = await iterator.next() else { - Issue.record("Expected the first fetched-results publication to be initial state.") - return - } - - #expect(revision == results.revision) - #expect(snapshot == results.snapshot) - #expect(snapshot.itemIDs == [requestID]) -} - -@MainActor -@Test -func fetchedResultsUpdatesCannotMissMutationAfterRegistration() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let results: WebInspectorFetchedResults = context.fetchedResults() - var iterator = results.updates().makeAsyncIterator() - - let requestID = seedFetchedResultsRequest( - "registered-before-mutation", - url: "https://example.com/registered", - timestamp: 1, - in: context - ) - - guard case let .initial(revision, snapshot)? = await iterator.next() else { - Issue.record("Expected the pending initial value to coalesce to current state.") - return - } - - #expect(revision == results.revision) - #expect(snapshot == results.snapshot) - #expect(snapshot.itemIDs == [requestID]) -} - -@MainActor -@Test -func fetchedResultsPropertyUpdatesDoNotInvalidateTopologyQueries() { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let requestID = seedFetchedResultsRequest( - "property-generation", - url: "https://example.com/original", - timestamp: 1, - in: context - ) - let results: WebInspectorFetchedResults = context.fetchedResults() - let initialRevision = results.revision - let initialTopologyRevision = results.topologyRevision - - seedFetchedResultsRequest( - "property-generation", - url: "https://example.com/updated", - timestamp: 2, - in: context - ) - - #expect(results.revision == initialRevision &+ 1) - #expect(results.topologyRevision == initialTopologyRevision) - #expect(results.snapshot.itemIDs == [requestID]) -} - -@MainActor -@Test -func networkRequestIndexDrainsDifferentIDMutationsInSequenceOrder() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let firstID = seedFetchedResultsRequest( - "ordered-first", - url: "https://example.com/first", - timestamp: 1, - in: context - ) - let secondID = seedFetchedResultsRequest( - "ordered-second", - url: "https://example.com/second", - timestamp: 2, - in: context - ) - let first = try #require(context.registeredRequest(for: firstID)) - let second = try #require(context.registeredRequest(for: secondID)) - let index = NetworkRequestIndex() - - let secondMutation = Task { - await index.upsert( - NetworkRequestRecordInput(request: second, orderIndex: 1), - sequence: 2 - ) - } - try await waitUntil { - await index.isMutationPendingForTesting(sequence: 2) - } - await index.upsert( - NetworkRequestRecordInput(request: first, orderIndex: 0), - sequence: 1 - ) - _ = await secondMutation.value - - let delta = await index.delta( - plan: NetworkRequestQueryPlan( - descriptor: WebInspectorFetchDescriptor(), - context: context - ), - sectionBy: nil, - oldSnapshot: WebInspectorFetchedResultsSnapshot(), - changedSince: 0 - ) - #expect(delta.sequence == 2) - #expect(delta.snapshot.itemIDs == [firstID, secondID]) -} - -@MainActor -@Test -func networkRequestIndexBuffersUpsertUntilEarlierReplaceArrives() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let firstID = seedFetchedResultsRequest( - "replace-first", - url: "https://example.com/first", - timestamp: 1, - in: context - ) - let secondID = seedFetchedResultsRequest( - "replace-second", - url: "https://example.com/second", - timestamp: 2, - in: context - ) - let first = try #require(context.registeredRequest(for: firstID)) - let second = try #require(context.registeredRequest(for: secondID)) - let index = NetworkRequestIndex() - - let upsert = Task { - await index.upsert( - NetworkRequestRecordInput(request: second, orderIndex: 1), - sequence: 2 - ) - } - try await waitUntil { - await index.isMutationPendingForTesting(sequence: 2) - } - await index.replace( - with: [NetworkRequestRecordInput(request: first, orderIndex: 0)], - sequence: 1 - ) - _ = await upsert.value - - let delta = await index.delta( - plan: NetworkRequestQueryPlan( - descriptor: WebInspectorFetchDescriptor(), - context: context - ), - sectionBy: nil, - oldSnapshot: WebInspectorFetchedResultsSnapshot(), - changedSince: 0 - ) - #expect(delta.sequence == 2) - #expect(delta.snapshot.itemIDs == [firstID, secondID]) -} - -@MainActor -@Test -func networkRequestIndexCheckpointRecoversSkippedOverlappingPropertyUpdates() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let firstID = seedFetchedResultsRequest( - "overlap-first", - url: "https://example.com/first", - timestamp: 1, - in: context - ) - let secondID = seedFetchedResultsRequest( - "overlap-second", - url: "https://example.com/second", - timestamp: 2, - in: context - ) - let first = try #require(context.registeredRequest(for: firstID)) - let second = try #require(context.registeredRequest(for: secondID)) - let plan = NetworkRequestQueryPlan( - descriptor: WebInspectorFetchDescriptor(), - context: context - ) - let index = NetworkRequestIndex() - await index.replace( - with: [ - NetworkRequestRecordInput(request: first, orderIndex: 0), - NetworkRequestRecordInput(request: second, orderIndex: 1), - ], - sequence: 1 - ) - let initial = await index.delta( - plan: plan, - sectionBy: nil, - oldSnapshot: WebInspectorFetchedResultsSnapshot(), - changedSince: 0 - ) - #expect(initial.sequence == 1) - #expect(initial.snapshot.itemIDs == [firstID, secondID]) - - seedFetchedResultsRequest( - "overlap-first", - url: "https://example.com/first-updated", - timestamp: 3, - in: context - ) - await index.upsert( - NetworkRequestRecordInput(request: first, orderIndex: 0), - sequence: 2 - ) - let skippedFirstDelta = await index.delta( - plan: plan, - sectionBy: nil, - oldSnapshot: initial.snapshot, - changedSince: initial.sequence - ) - #expect(skippedFirstDelta.reconfigureItemIDs == [firstID]) - - seedFetchedResultsRequest( - "overlap-second", - url: "https://example.com/second-updated", - timestamp: 4, - in: context - ) - await index.upsert( - NetworkRequestRecordInput(request: second, orderIndex: 1), - sequence: 3 - ) - - // Model the context discarding `skippedFirstDelta` after a newer index - // sequence arrived. The next delta starts at the result's unchanged - // checkpoint and must recover both property notifications. - let recoveredDelta = await index.delta( - plan: plan, - sectionBy: nil, - oldSnapshot: initial.snapshot, - changedSince: initial.sequence - ) - #expect(recoveredDelta.sequence == 3) - #expect(recoveredDelta.reconfigureItemIDs == Set([firstID, secondID])) - #expect(recoveredDelta.snapshot == initial.snapshot) -} - -@MainActor -@Test -func stalledFetchedResultsSubscriberKeepsOnlyNewestUpdateAndUnionsReconfigurations() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let firstID = seedFetchedResultsRequest( - "stalled-first", - url: "https://example.com/first", - timestamp: 1, - in: context - ) - let secondID = seedFetchedResultsRequest( - "stalled-second", - url: "https://example.com/second", - timestamp: 2, - in: context - ) - let firstIdentity = try #require(context.registeredRequest(for: firstID)) - let secondIdentity = try #require(context.registeredRequest(for: secondID)) - - var results: WebInspectorFetchedResults? = context.fetchedResults() - weak let weakResults = results - var iterator = try #require(results).updates().makeAsyncIterator() - guard case .initial? = await iterator.next() else { - Issue.record("Expected fetched-results initial state before stalling the subscriber.") - return - } - - seedFetchedResultsRequest( - "stalled-first", - url: "https://example.com/first-updated", - timestamp: 3, - in: context - ) - seedFetchedResultsRequest( - "stalled-second", - url: "https://example.com/second-updated", - timestamp: 4, - in: context - ) - - let expectedRevision = try #require(results).revision - let expectedSnapshot = try #require(results).snapshot - results = nil - #expect(weakResults == nil) - #expect(context.registeredRequest(for: firstID) === firstIdentity) - #expect(context.registeredRequest(for: secondID) === secondIdentity) - - guard case let .transaction(revision, transaction, reconfigureItemIDs)? = await iterator.next() else { - Issue.record("Expected the newest coalesced fetched-results transaction.") - return - } - #expect(revision == expectedRevision) - #expect(transaction.newSnapshot == expectedSnapshot) - #expect(reconfigureItemIDs == Set([firstID, secondID])) - - let remainingUpdate = await iterator.next() - #expect(remainingUpdate == nil) -} - -@MainActor -@Test -func stalledFetchedResultsSubscriberDoesNotReconfigureRemovedItems() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let requestID = seedFetchedResultsRequest( - "stalled-removed", - url: "https://example.com/original", - timestamp: 1, - in: context - ) - var results: WebInspectorFetchedResults? = context.fetchedResults() - var iterator = try #require(results).updates().makeAsyncIterator() - guard case .initial? = await iterator.next() else { - Issue.record("Expected fetched-results initial state before stalling the subscriber.") - return - } - - seedFetchedResultsRequest( - "stalled-removed", - url: "https://example.com/updated", - timestamp: 2, - in: context - ) - await context.clearNetworkRequests() - - let expectedRevision = try #require(results).revision - results = nil - - guard case let .transaction(revision, transaction, reconfigureItemIDs)? = await iterator.next() else { - Issue.record("Expected the coalesced removal transaction.") - return - } - #expect(revision == expectedRevision) - #expect(transaction.isReset) - #expect(transaction.newSnapshot.itemIDs.isEmpty) - #expect(reconfigureItemIDs.isEmpty) - #expect(context.registeredRequest(for: requestID) == nil) - - let remainingUpdate = await iterator.next() - #expect(remainingUpdate == nil) -} - -@MainActor -@Test -func fetchedResultsPublishNetworkTopologyAndPropertyUpdates() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let requestID = Network.Request.ID("controller-request") - try await runtime.wire.emitRaw( - .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(recorder.reconfigureItemIDSets.last == []) - #expect(results.snapshot.itemIDs == [modelID]) - let request = try #require(results.items.first) - - try await runtime.wire.emitRaw( - .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 } - try await recorder.waitForTransactionCount(2) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), - target: target - ) - - try await waitUntil { request.decodedDataLength == 7 && request.encodedDataLength == 3 } - try await recorder.waitForTransactionCount(3) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - try await runtime.wire.emitRaw( - .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), - target: target - ) - - try await waitUntil { request.state == .finished } - try await recorder.waitForTransactionCount(4) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - try await runtime.wire.emitRaw( - .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 } - try await recorder.waitForTransactionCount(5) - #expect(results.items.first === request) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: modelID, indexPath: firstIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [modelID]) - - let failedRequestID = Network.Request.ID("controller-failed-request") - let failedModelID = NetworkRequest.ID(failedRequestID) - let failedIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 1) - try await runtime.wire.emitRaw( - .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(6) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: failedModelID, indexPath: failedIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == []) - let failedRequest = try #require(results.items.last) - - try await runtime.wire.emitRaw( - .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 - } - try await recorder.waitForTransactionCount(7) - #expect(results.items.last === failedRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: failedModelID, indexPath: failedIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [failedModelID]) - - let socketRequestID = Network.Request.ID("controller-socket-request") - let socketModelID = NetworkRequest.ID(socketRequestID) - let socketIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 2) - try await runtime.wire.emitRaw( - .webSocket(.created(id: socketRequestID, url: "wss://example.com/socket")), - target: target - ) - - try await recorder.waitForTransactionCount(8) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == []) - let socketRequest = try #require(results.items.last) - - try await runtime.wire.emitRaw( - .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" } - try await recorder.waitForTransactionCount(9) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - - try await runtime.wire.emitRaw( - .webSocket(.handshakeResponse( - id: socketRequestID, - response: Network.Response(status: 101, statusText: "Switching Protocols"), - timestamp: 9 - )), - target: target - ) - - try await waitUntil { socketRequest.status == 101 } - try await recorder.waitForTransactionCount(10) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - - try await runtime.wire.emitRaw( - .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 } - try await recorder.waitForTransactionCount(11) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - - try await runtime.wire.emitRaw( - .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 } - try await recorder.waitForTransactionCount(12) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - - try await runtime.wire.emitRaw( - .webSocket(.error(id: socketRequestID, message: "decode failed", timestamp: 12)), - target: target - ) - - try await waitUntil { socketRequest.webSocket?.frames.count == 3 } - try await recorder.waitForTransactionCount(13) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - - try await runtime.wire.emitRaw( - .webSocket(.closed(id: socketRequestID, timestamp: 13)), - target: target - ) - - try await waitUntil { socketRequest.state == .finished && socketRequest.webSocket?.readyState == .closed } - try await recorder.waitForTransactionCount(14) - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: socketModelID, indexPath: socketIndexPath)]) - #expect(recorder.reconfigureItemIDSets.last == [socketModelID]) - } -} - -@MainActor -@Test -func sectionedNetworkResultsPublishTopologyWhenSectionKeyChanges() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.mimeType) - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let requestID = Network.Request.ID("sectioned-controller-request") - let modelID = NetworkRequest.ID(requestID) - try await runtime.wire.emitRaw( - .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(results.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) - #expect(results.snapshot.itemIDs == [modelID]) - - try await runtime.wire.emitRaw( - .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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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(results.snapshot.sections.map(\.id) == [ - WebInspectorFetchSectionID(rawValue: "image"), - WebInspectorFetchSectionID(rawValue: "xhrFetch"), - ]) - #expect(results.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")), - ]) - - await 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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.wire) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] - } - - await enqueueDomainDisableReplies(on: runtime.wire) - try await enqueueStartupReplies( - on: runtime.wire, - 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") - try await emitFinishedRequest(id: freshRequestID, target: target, backend: runtime.wire) - 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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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 recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let firstRequestID = Network.Request.ID("clear-first-request") - let firstModelID = NetworkRequest.ID(firstRequestID) - try await runtime.wire.emitRaw( - .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) - try await runtime.wire.emitRaw( - .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(results.snapshot.itemIDs == [firstModelID, secondModelID]) - - await 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(results.snapshot.itemIDs == []) - #expect(results.items.isEmpty) - #expect(context.registeredRequest(for: firstModelID) == nil) - #expect(context.registeredRequest(for: secondModelID) == nil) - - let clearedEventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw( - .responseReceived( - id: firstRequestID, - response: Network.Response(status: 200), - resourceType: .fetch, - timestamp: 3 - ), - target: target - ) - try await runtime.wire.emitRaw( - .dataReceived(id: firstRequestID, dataLength: 7, encodedDataLength: 4, timestamp: 4), - target: target - ) - try await runtime.wire.emitRaw( - .loadingFinished(id: secondRequestID, timestamp: 5, sourceMapURL: nil, metrics: nil), - target: target - ) - try await runtime.wire.emitRaw( - .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 - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - - let apiRequestID = Network.Request.ID("queryable-api-request") - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .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") - try await runtime.wire.emitRaw( - .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 fetchedResultsPublishConsoleInsertUpdateAndDeleteTransactions() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - try await runtime.wire.emitRaw( - .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(results.snapshot.itemIDs.first) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0))]) - - try await runtime.wire.emitRaw( - .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))]) - - try await runtime.wire.emitRaw( - .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(results.snapshot.itemIDs.last) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1))]) - - try await runtime.wire.emitRaw( - .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(results.snapshot.itemIDs == []) - } -} - -@MainActor -@Test -func fetchedResultsCanBeSectionedByStringKeyPath() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.method) - let recorder = FetchedResultsTransactionRecorder(stream: results.updates()) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let getID = Network.Request.ID("sectioned-get") - try await runtime.wire.emitRaw( - .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(results.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") - try await runtime.wire.emitRaw( - .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") - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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) - - try await enqueueCSSStyleReplies(on: runtime.wire) - - 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 = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "CSS.enable" } == false) - #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" }) - #expect(commands.contains { $0.method == "CSS.getInlineStylesForNode" }) - #expect(commands.contains { $0.method == "CSS.getComputedStyleForNode" }) - } -} - -@MainActor -@Test -func selectingDOMNodeRetriesCSSStyleLoadAfterEnablingAgent() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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.wire.fail( - "CSS.getMatchedStylesForNode", - message: "CSS agent is not enabled." - ) - await runtime.wire.respond(to: "CSS.enable") - try await enqueueCSSStyleReplies(on: runtime.wire) - - 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 = runtime.wire.observations.commands - .filter { $0.method.hasPrefix("CSS.") } - #expect(cssCommands.map(\.method) == [ - "CSS.getMatchedStylesForNode", - "CSS.enable", - "CSS.getMatchedStylesForNode", - "CSS.getInlineStylesForNode", - "CSS.getComputedStyleForNode", - ]) - } -} - -@MainActor -@Test -func selectingFrameScopedDOMNodeRetriesCSSStyleLoadByEnablingFrameAgent() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let frameTargetRawValue = "frame-css-agent" - _ = try await createFrameTarget( - in: runtime, - id: frameTargetRawValue, - frameID: "frame-css-agent" - ) - let elementID = DOM.Node.ID("frame-styled-node", scopedToTargetRawValue: frameTargetRawValue) - - try await runtime.wire.emitRaw( - .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.wire.fail( - "CSS.getMatchedStylesForNode", - message: "CSS agent is not enabled." - ) - await runtime.wire.respond(to: "CSS.enable") - try await enqueueCSSStyleReplies(on: runtime.wire) - - context.select(element) - - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - let enableCommand = runtime.wire.observations.commands.first { - $0.method == "CSS.enable" - } - #expect(enableCommand?.destination == .target(frameTargetRawValue)) - } -} - -@MainActor -@Test -func selectingNonElementDOMNodeDoesNotRequestCSSStyles() async throws { - try await withDataKitTestRuntime { runtime in - let (_, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - - context.select(document) - - #expect(document.elementStyles == nil) - let commands = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "CSS.getMatchedStylesForNode" } == false) - #expect(commands.contains { $0.method == "CSS.getComputedStyleForNode" } == false) - } -} - -@MainActor -@Test -func cssEventsAndSelectedDOMMutationsMarkSelectedStylesStale() async throws { - try await withDataKitTestRuntime { runtime in - 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") - - try await runtime.wire.emitRaw( - .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) }) - - try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(selected) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } - - @MainActor - func reloadSelectedStyles() async throws { - try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(selected) - try await waitUntil { styles.phase == .loaded } - } - - try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - try await runtime.wire.emitRaw( - .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() - - try await runtime.wire.emitRaw(.styleSheetRemoved(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - try await runtime.wire.emitRaw(.mediaQueryResultChanged, target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - let otherAttributeBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw(.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 - try await runtime.wire.emitRaw(.nodeLayoutFlagsChanged(otherID), target: target) - let didProcessOtherLayout = await context.waitForEventPumpAppliedSequenceForTesting( - after: otherLayoutBaseline - ) - #expect(didProcessOtherLayout) - #expect(styles.phase == .loaded) - - try await runtime.wire.emitRaw(.nodeLayoutFlagsChanged(selectedID), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - try await runtime.wire.emitRaw(.attributeModified(selectedID, name: "class", value: "changed"), target: target) - try await waitUntil { styles.phase == .needsRefresh } - } -} - -@MainActor -@Test -func cssInvalidationDuringStyleFetchIsNotOverwrittenByStaleResult() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - let computedGate = await runtime.wire.deferReply( - to: "CSS.getComputedStyleForNode", - with: try rawCSSComputedStyleResult([ - CSS.ComputedProperty(name: "display", value: "grid"), - ]) - ) - - try await runtime.wire.emitRaw( - .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.wire.respond( - to: "CSS.getMatchedStylesForNode", - with: try rawCSSMatchedStylesResult(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")) - ) - ])) - ) - await runtime.wire.respond( - to: "CSS.getInlineStylesForNode", - with: try rawCSSInlineStylesResult(CSS.InlineStyles()) - ) - - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { - runtime.wire.observations.commandMethods.contains( - "CSS.getComputedStyleForNode" - ) - } - - try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - computedGate.open() - _ = await runtime.wire.observations.waitForCompletedCommands( - method: "CSS.getComputedStyleForNode", - count: 1 - ) - #expect(styles.phase == .needsRefresh) - #expect(styles.computedProperties.isEmpty) - } -} - -@MainActor -@Test -func selectingDOMNodeLoadsInlineAndAttributesStyleSections() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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.wire.respond( - to: "CSS.getMatchedStylesForNode", - with: try rawCSSMatchedStylesResult(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;") - ) - ])) - ) - await runtime.wire.respond( - to: "CSS.getInlineStylesForNode", - with: try rawCSSInlineStylesResult(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") - ] - ) - )) - ) - await runtime.wire.respond( - to: "CSS.getComputedStyleForNode", - with: try rawCSSComputedStyleResult([ - CSS.ComputedProperty(name: "display", value: "grid"), - ]) - ) - - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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.setStyleHydrationActive(true) - try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - try await enqueueCSSStyleReplies(on: runtime.wire) - try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - - try await waitUntil { - matchedStylesCommandCount(on: runtime.wire) == 2 - } - try await waitUntil { styles.phase == .loaded } - } -} - -@MainActor -@Test -func styleSheetChangedWhileHydrationInactiveDefersRefetchUntilActivation() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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) - - try await enqueueCSSStyleReplies(on: runtime.wire) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - #expect(styles.phase == .needsRefresh) - #expect(matchedStylesCommandCount(on: runtime.wire) == 1) - - try await enqueueCSSStyleReplies(on: runtime.wire) - context.setStyleHydrationActive(true) - - try await waitUntil { styles.phase == .loaded } - #expect(matchedStylesCommandCount(on: runtime.wire) == 2) - } -} - -@MainActor -@Test -func requestSetCSSPropertyTogglesDeclarationAndRefreshesStyles() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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.setStyleHydrationActive(true) - try await enqueueCSSStyleReplies(on: runtime.wire) - 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.wire.respond( - to: "CSS.setStyleText", - with: try rawCSSStyleResult(disabledStyle) - ) - await runtime.wire.respond( - to: "CSS.getMatchedStylesForNode", - with: try rawCSSMatchedStylesResult(CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: disabledStyle - ) - ])) - ) - await runtime.wire.respond( - to: "CSS.getInlineStylesForNode", - with: try rawCSSInlineStylesResult(CSS.InlineStyles()) - ) - await runtime.wire.respond( - to: "CSS.getComputedStyleForNode", - with: try rawCSSComputedStyleResult([ - CSS.ComputedProperty(name: "display", value: "grid"), - ]) - ) - - let propertyID = try #require(styles.sections.first?.style.properties.first?.id) - await runtime.wire.respond(to: "DOM.markUndoableState") - #expect(context.requestSetCSSProperty( - propertyID, - enabled: false, - options: .automatic - )) - - try await waitUntil { - matchedStylesCommandCount(on: runtime.wire) == 2 - } - try await waitUntil { styles.phase == .loaded } - - let commands = runtime.wire.observations.commands - let setStyleText = try #require(commands.last { $0.method == "CSS.setStyleText" }) - #expect(try commandNestedStringParameter( - setStyleText, - object: "styleId", - key: "styleSheetId" - ) == "style-1") - #expect(try commandStringParameter(setStyleText, "text") == "/* display: grid; */") - let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } - #expect(undoMarks.count == 1) - #expect(undoMarks.first?.destination == .target(wireTargetID(target))) - - let property = try #require(styles.sections.first?.style.properties.first) - #expect(property.status == .disabled) - #expect(property.isModifiedByInspector) - } -} - -@MainActor -@Test -func setCSSDeclarationTextRewritesStyleTextAndMarksUndoableState() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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) - - try await enqueueCSSStyleReplies(on: runtime.wire) - 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.wire.respond( - to: "CSS.setStyleText", - with: try rawCSSStyleResult(updatedStyle) - ) - await runtime.wire.respond(to: "DOM.markUndoableState") - - let propertyID = try #require(styles.sections.first?.style.properties.first?.id) - try await context.setCSSDeclarationText( - "display: flex;", - for: propertyID, - options: .automatic - ) - - let commands = runtime.wire.observations.commands - let setStyleText = try #require(commands.last { $0.method == "CSS.setStyleText" }) - #expect(try commandNestedStringParameter( - setStyleText, - object: "styleId", - key: "styleSheetId" - ) == "style-1") - #expect(try commandStringParameter(setStyleText, "text") == "display: flex;") - let undoMarks = commands.filter { $0.method == "DOM.markUndoableState" } - #expect(undoMarks.count == 1) - #expect(undoMarks.first?.destination == .target(wireTargetID(target))) - - 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 { - try await withDataKitTestRuntime { runtime in - let (_, context) = try await startContext(runtime: runtime) - let frameTarget = try await createFrameTarget(in: runtime) - let proxyRuleID = CSS.Rule.ID( - "frame-sheet\u{1F}3", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - let ruleID = CSSStyleRule.ID(proxyRuleID) - - await runtime.wire.respond( - to: "CSS.setRuleSelector", - with: try rawCSSRuleResult(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-sheet\u{1F}4", - scopedToTargetRawValue: frameTarget.id.rawValue - ), - isEditable: true - ) - )) - ) - await runtime.wire.respond(to: "DOM.markUndoableState") - - try await context.setCSSRuleSelector( - ".updated", - for: ruleID, - options: .automatic - ) - - let commands = runtime.wire.observations.commands - let setRuleSelector = try #require(commands.first { $0.method == "CSS.setRuleSelector" }) - #expect(setRuleSelector.destination == .target(wireTargetID(frameTarget))) - #expect(try commandNestedStringParameter( - setRuleSelector, - object: "ruleId", - key: "styleSheetId" - ) == "frame-sheet") - - let markUndoableState = try #require(commands.first { $0.method == "DOM.markUndoableState" }) - #expect(markUndoableState.destination == .target(wireTargetID(frameTarget))) - } -} - -@MainActor -@Test -func requestSetCSSPropertyRefusesStaleAndNonEditableProperties() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - try await runtime.wire.emitRaw( - .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.wire.respond( - to: "CSS.getMatchedStylesForNode", - with: try rawCSSMatchedStylesResult(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 - ) - ), - ])) - ) - await runtime.wire.respond( - to: "CSS.getInlineStylesForNode", - with: try rawCSSInlineStylesResult(CSS.InlineStyles()) - ) - await runtime.wire.respond( - to: "CSS.getComputedStyleForNode", - with: try rawCSSComputedStyleResult([ - CSS.ComputedProperty(name: "display", value: "grid"), - ]) - ) - 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.requestSetCSSProperty( - userAgentPropertyID, - enabled: false, - options: .automatic - ) == false) - - let editableSection = try #require(styles.sections.first { $0.title == ".card" }) - let editablePropertyID = try #require(editableSection.style.properties.first?.id) - - try await runtime.wire.emitRaw(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - #expect(context.requestSetCSSProperty( - editablePropertyID, - enabled: false, - options: .automatic - ) == false) - - let commands = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "CSS.setStyleText" } == false) - } -} - -@MainActor -@Test -func removingLoadedChildPurgesDescendantsFromIdentityMap() async throws { - try await withDataKitTestRuntime { runtime in - 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") - - try await enqueueStartupReplies( - on: runtime.wire, - 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 } - - try await runtime.wire.emitRaw( - .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 - } - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await enqueueStartupReplies( - on: runtime.wire, - 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 } - - try await runtime.wire.emitRaw( - .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))) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await enqueueStartupReplies( - on: runtime.wire, - 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 } - - try await runtime.wire.emitRaw( - .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))) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - 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") - - try await enqueueStartupReplies( - on: runtime.wire, - 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 } - - try await runtime.wire.emitRaw( - .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))) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let documentID = DOM.Node.ID("document") - - let gate = await runtime.wire.deferReply( - to: "DOM.getDocument", - with: try domDocumentResult( - DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - ) - await enqueueDomainEnableReplies(on: runtime.wire) - await enqueueDomainDisableReplies(on: runtime.wire) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let startupTask = try #require(context.startupTaskForTesting()) - try await waitUntil { - runtime.wire.observations.commands - .contains { $0.method == "DOM.getDocument" } - } - - await container.close() - #expect(context.state == .detached) - - gate.open() - await startupTask.value - _ = await runtime.wire.observations.waitForCommands(method: "Inspector.disable", count: 1) - - #expect(context.state == .detached) - #expect(context.rootNode == nil) - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == Array(startupCommands.prefix(5)) + [ - "Runtime.disable", - "Network.disable", - "Inspector.disable", - ]) - } -} - -@MainActor -@Test -func stopDuringStartupReleasesLateRuntimeAcquire() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let gate = await runtime.wire.deferReply(to: "Runtime.enable") - - await runtime.wire.respond(to: "Inspector.enable") - await runtime.wire.respond(to: "Inspector.initialized") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - ] - } - - await runtime.wire.respond(to: "Inspector.disable") - await context.stop() - #expect(context.state == .detached) - - await runtime.wire.respond(to: "Runtime.disable") - gate.open() - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Inspector.disable", - "Runtime.disable", - ] - } - #expect(context.state == .detached) - } -} - -@MainActor -@Test -func domainEnablementReleaseDuringPendingEnableDisablesAfterEnableCompletes() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let gate = await runtime.wire.deferReply(to: "Runtime.enable") - - let acquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Runtime.enable" - ] - } - - let releaseTask = Task { - await registry.release(.runtime, on: target) - } - - await runtime.wire.respond(to: "Runtime.disable") - gate.open() - - try await acquireTask.value - #expect(await releaseTask.value == nil) - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Runtime.enable", - "Runtime.disable", - ]) - } -} - -@MainActor -@Test -func domainEnablementAcquireWaitsForFinalReleaseDisable() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let disableGate = await runtime.wire.deferReply(to: "Runtime.disable") - - await runtime.wire.respond(to: "Runtime.enable") - try await registry.acquire(.runtime, on: target) - - let releaseTask = Task { - await registry.release(.runtime, on: target) - } - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - ] - } - - let acquireWaitBaseline = await registry.acquireWaitingForDisableSequenceForTesting - let acquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - await registry.waitForAcquireWaitingForDisableForTesting(after: acquireWaitBaseline) - #expect(runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - ]) - - await runtime.wire.respond(to: "Runtime.enable") - disableGate.open() - - #expect(await releaseTask.value == nil) - try await acquireTask.value - #expect(runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - "Runtime.enable", - ]) - } -} - -@MainActor -@Test -func domainEnablementAcquireWaitsForPendingReleaseDisable() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let enableGate = await runtime.wire.deferReply(to: "Runtime.enable") - let disableGate = await runtime.wire.deferReply(to: "Runtime.disable") - - let firstAcquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Runtime.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) - - enableGate.open() - try await waitUntil { - runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - ] - } - #expect(runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - ]) - - await runtime.wire.respond(to: "Runtime.enable") - disableGate.open() - - try await firstAcquireTask.value - #expect(await releaseTask.value == nil) - try await secondAcquireTask.value - #expect(runtime.wire.observations.commandMethods == [ - "Runtime.enable", - "Runtime.disable", - "Runtime.enable", - ]) - } -} - -@MainActor -@Test -func domainEnablementDiscardLeasePreservesSharedEnabledLease() async throws { - try await withDataKitTestRuntime { runtime in - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - - await runtime.wire.respond(to: "Runtime.enable") - try await registry.acquire(.runtime, on: target) - try await registry.acquire(.runtime, on: target) - - await registry.discardLease(.runtime, on: target) - - await runtime.wire.respond(to: "Runtime.disable") - #expect(await registry.release(.runtime, on: target) == nil) - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Runtime.enable", - "Runtime.disable", - ]) - } -} - -@MainActor -@Test -func domainLeaseRetargetInterleavingReenablesCommittedPageBinding() async throws { - try await withDataKitTestRuntime { runtime in - 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.wire.respond(to: "Runtime.enable") - try await registry.acquire(.runtime, on: oldPage) - try await registry.acquire(.runtime, on: oldPage) - - await registry.discardLease(.runtime, on: oldPage) - await runtime.wire.respond(to: "Runtime.enable") - try await registry.acquire(.runtime, on: newPage) - - await registry.discardLease(.runtime, on: oldPage) - try await registry.acquire(.runtime, on: newPage) - - let commands = runtime.wire.observations.commands - #expect(commands.map(\.method) == [ - "Runtime.enable", - "Runtime.enable", - ]) - } -} - -@MainActor -@Test -func networkEventsPopulateAllRequestsInOrder() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-1") - - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: 12, encodedDataLength: 5, timestamp: 3), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("response-first-request") - - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: 9, encodedDataLength: 4, timestamp: 3), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-with-terminal-metadata") - - try await runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 2, timestamp: 2), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-with-negative-terminal-metrics") - - try await runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/negative", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 4, timestamp: 2), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("redirected-request") - - try await runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/redirect", method: "GET"), - resourceType: .document, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .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 - } - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("reused-request") - - try await runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/first", method: "GET"), - resourceType: .document, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("failed-request") - - try await runtime.wire.emitRaw( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/fail", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await runtime.wire.emitRaw( - .dataReceived(id: requestID, dataLength: -10, encodedDataLength: -20, timestamp: 2), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("cached-request") - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - - await #expect(throws: RawWireFixtureError.missingRequiredField( - method: "Network.requestServedFromMemoryCache", - field: "resource.url" - )) { - try await runtime.wire.emitRaw( - .requestServedFromMemoryCache( - id: Network.Request.ID("cached-request-without-url"), - response: Network.Response(status: 200), - resourceType: nil, - timestamp: 5 - ), - target: target - ) - } - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-created") - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-created-after-request") - - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .responseReceived( - id: requestID, - response: Network.Response( - status: 101, - statusText: "Switching Protocols", - headers: ["Upgrade": "websocket"], - requestHeaders: ["Upgrade": "websocket"] - ), - resourceType: .webSocket, - timestamp: 2 - ), - target: target - ) - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .webSocket(.created(id: requestID, url: "wss://example.com/socket?created")), - target: target - ) - try await waitUntil { request.url == "wss://example.com/socket?created" } - try await runtime.wire.emitRaw( - .webSocket(.handshakeRequest( - id: requestID, - request: Network.Request( - id: requestID, - url: "", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - timestamp: nil - )), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-lifecycle") - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .webSocket(.frameSent( - id: requestID, - frame: Network.WebSocketFrame(opcode: 1, mask: true, payloadData: "hello", payloadLength: 5), - timestamp: 3 - )), - target: target - ) - try await runtime.wire.emitRaw( - .webSocket(.frameReceived( - id: requestID, - frame: Network.WebSocketFrame(opcode: 1, mask: false, payloadData: "world", payloadLength: 5), - timestamp: 4 - )), - target: target - ) - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("missing-websocket") - - try await runtime.wire.emitRaw( - .webSocket(.handshakeResponse( - id: requestID, - response: Network.Response(status: 101), - timestamp: 1 - )), - target: target - ) - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - - let baseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("form-request") - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("form-request-hints") - - try await runtime.wire.emitRaw( - .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") - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("json-response-body") - - try await runtime.wire.emitRaw( - .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 - ) - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .loadingFinished(id: requestID, timestamp: 3, sourceMapURL: nil, metrics: nil), - target: target - ) - try await waitUntil { request.canFetchResponseBody } - - await runtime.wire.respond( - to: "Network.getResponseBody", - with: try rawNetworkBodyResult( - Network.Body(data: #"{"ok":true}"#, base64Encoded: false) - ) - ) - - 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 = runtime.wire.observations.commands - await request.fetchResponseBody() - let commandsAfterSecondFetch = runtime.wire.observations.commands - #expect(commandsAfterSecondFetch == commandsBeforeSecondFetch) - } -} - -@MainActor -@Test -func fetchResponseBodyStoresLoadedAndFailedPhases() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let loadedID = Network.Request.ID("loaded-request") - let failedID = Network.Request.ID("failed-request") - - try await emitFinishedRequest(id: loadedID, target: target, backend: runtime.wire) - try await emitFinishedRequest(id: failedID, target: target, backend: runtime.wire) - - 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.wire.respond( - to: "Network.getResponseBody", - with: try rawNetworkBodyResult( - Network.Body(data: "hello", base64Encoded: false) - ) - ) - await runtime.wire.fail( - "Network.getResponseBody", - message: "Response body is unavailable." - ) - - 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, got \(error).") - return - } - - let commands = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "Network.getResponseBody" }) - } -} - -@MainActor -@Test -func fetchResponseBodyDropsCompletionAfterNetworkClear() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("cleared-body-request") - let gate = await runtime.wire.deferReply( - to: "Network.getResponseBody", - with: try rawNetworkBodyResult( - Network.Body(data: "stale-body", base64Encoded: false) - ) - ) - - try await emitFinishedRequest(id: requestID, target: target, backend: runtime.wire) - 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 - let fetchTask = Task { - await request.fetchResponseBody() - } - try await waitUntil { - runtime.wire.observations.commands.contains { $0.method == "Network.getResponseBody" } - } - - await context.clearNetworkRequests() - #expect(context.registeredRequest(for: NetworkRequest.ID(requestID)) == nil) - 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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-1") - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .messageRepeatCountUpdated(count: 3, timestamp: 2), - target: target - ) - try await waitUntil { message.repeatCount == 3 } - #expect(results.items.first === message) - #expect(message.timestamp == 2) - - try await runtime.wire.emitRaw( - .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"]) - - try await runtime.wire.emitRaw( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - target: target - ) - try await waitUntil { results.items.isEmpty } - #expect(context.registeredMessage(for: message.id) == nil) - } -} - -@MainActor -@Test -func consoleFetchedResultsHonorDescriptorsForInitialUpdatesAndDescriptorChanges() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let allResults: WebInspectorFetchedResults = context.fetchedResults() - - try await runtime.wire.emitRaw( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "middle" - )), - target: target - ) - try await runtime.wire.emitRaw( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "error"), - text: "zeta" - )), - target: target - ) - try await runtime.wire.emitRaw( - .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"]) - - try await runtime.wire.emitRaw( - .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 { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let objectID = Runtime.RemoteObject.ID("console-object") - let results: WebInspectorFetchedResults = context.fetchedResults() - - try await runtime.wire.emitRaw( - .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") - - try await runtime.wire.emitRaw( - .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 consoleMessagesClearedInvalidatesRuntimeObjectsWithoutRuntimeCommand() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let objectID = Runtime.RemoteObject.ID("console-stale-object") - let results: WebInspectorFetchedResults = context.fetchedResults() - - let messageEventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw( - .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 - ) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: messageEventBaseline)) - try await waitUntil { results.items.count == 1 } - let message = try #require(results.items.first) - let parameter = try #require(message.parameters.first) - - let commandCountBeforeClear = runtime.wire.observations.commands.count - let eventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - target: target - ) - - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - 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.")) - } - let clearCommands = runtime.wire.observations.commands.dropFirst(commandCountBeforeClear) - #expect(!clearCommands.contains { $0.method == "Runtime.releaseObjectGroup" }) - #expect(context.state == .attached) - } -} - -@MainActor -@Test -func evaluateRegistersRuntimeObjectInSelectedContext() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("main") - let objectID = Runtime.RemoteObject.ID("evaluation-result") - - try await runtime.wire.emitRaw( - .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.wire.respond( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: objectID, - kind: .string, - description: "hello", - value: .string("hello") - ), - wasThrown: true, - savedResultIndex: 7 - )) - ) - - 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 = runtime.wire.observations.commands - let command = try #require(commands.last { $0.method == "Runtime.evaluate" }) - #expect(try commandStringParameter(command, "expression") == "throw 'hello'") - #expect(try commandStringParameter(command, "contextId") == contextID.unscopedRawValue) - } -} - -@MainActor -@Test -func runtimeObjectPropertiesAndCollectionEntriesUseRuntimeCommands() async throws { - try await withDataKitTestRuntime { runtime in - 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.wire.respond( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject(id: objectID, kind: .object, description: "root") - )) - ) - let evaluation = try await context.evaluate("window") - - await runtime.wire.respond( - to: "Runtime.getProperties", - with: try rawRuntimePropertiesResult([ - 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") - ), - ]) - ) - - 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.wire.respond( - to: "Runtime.getCollectionEntries", - with: try rawRuntimeCollectionEntriesResult([ - Runtime.CollectionEntry( - key: Runtime.RemoteObject(id: nil, kind: .string, description: "key", value: .string("key")), - value: Runtime.RemoteObject(id: entryValueID, kind: .object, description: "entry value") - ) - ]) - ) - - 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 = runtime.wire.observations.commands - #expect(commands.contains { $0.method == "Runtime.getProperties" }) - #expect(commands.contains { $0.method == "Runtime.getCollectionEntries" }) - } -} - -@MainActor -@Test -func staleRuntimeObjectThrowsWithoutFailingContext() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("main") - let objectID = Runtime.RemoteObject.ID("stale-object") - - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Main", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - - await runtime.wire.respond( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject(id: objectID, kind: .object, description: "stale") - )) - ) - let evaluation = try await context.evaluate("window") - - try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil - } - - 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.")) - } - #expect(context.state == .attached) - } -} - -@MainActor -@Test -func runtimeEvaluationRejectsReplyAfterRuntimeClearWithoutKnownContexts() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - #expect(context.executionContexts.isEmpty) - let gate = await runtime.wire.deferReply( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-evaluation-reply"), - kind: .object - ) - )) - ) - - let evaluationTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await context.evaluate("window") - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected evaluation error: \(error)") - return nil - } - } - _ = await runtime.wire.observations.waitForCommands(method: "Runtime.evaluate", count: 1) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - gate.open() - - #expect(await evaluationTask.value == .disconnected( - "Runtime evaluation target is no longer current in this WebInspectorContext." - )) - #expect(context.state == .attached) - } -} - -@MainActor -@Test -func runtimePropertiesRejectReplyAfterObjectClear() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("properties-race") - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Properties race", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - await runtime.wire.respond( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("properties-root"), - kind: .object - ) - )) - ) - let root = try await context.evaluate("window").object - let gate = await runtime.wire.deferReply( - to: "Runtime.getProperties", - with: try rawRuntimePropertiesResult([ - Runtime.PropertyDescriptor( - name: "child", - value: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-property-child"), - kind: .object - ) - ) - ]) - ) - - let propertiesTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await root.properties() - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected Runtime.getProperties error: \(error)") - return nil - } - } - _ = await runtime.wire.observations.waitForCommands(method: "Runtime.getProperties", count: 1) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - gate.open() - - #expect(await propertiesTask.value == .disconnected( - "RuntimeObject is not registered in this WebInspectorContext." - )) - #expect(context.state == .attached) - } -} - -@MainActor -@Test -func runtimeCollectionEntriesRejectReplyAfterObjectClear() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("entries-race") - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Entries race", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - await runtime.wire.respond( - to: "Runtime.evaluate", - with: try rawRuntimeEvaluationResult(Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("entries-root"), - kind: .object - ) - )) - ) - let root = try await context.evaluate("new Map()").object - let gate = await runtime.wire.deferReply( - to: "Runtime.getCollectionEntries", - with: try rawRuntimeCollectionEntriesResult([ - Runtime.CollectionEntry( - value: Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("stale-entry-value"), - kind: .object - ) - ) - ]) - ) - - let entriesTask = Task { @MainActor () -> WebInspectorProxyError? in - do { - _ = try await root.collectionEntries() - return nil - } catch let error as WebInspectorProxyError { - return error - } catch { - Issue.record("Unexpected Runtime.getCollectionEntries error: \(error)") - return nil - } - } - _ = await runtime.wire.observations.waitForCommands(method: "Runtime.getCollectionEntries", count: 1) - let eventBaseline = context.eventPumpAppliedSequenceForTesting - try await runtime.wire.emitRaw(.executionContextsCleared(target: target.id), target: target) - #expect(await context.waitForEventPumpAppliedSequenceForTesting(after: eventBaseline)) - gate.open() - - #expect(await entriesTask.value == .disconnected( - "RuntimeObject is not registered in this WebInspectorContext." - )) - #expect(context.state == .attached) - } -} - -@MainActor -@Test -func runtimeEventsPopulateContextsAndFallbackSelection() async throws { - try await withDataKitTestRuntime { runtime in - let (target, context) = try await startContext(runtime: runtime) - let mainID = Runtime.ExecutionContext.ID("main") - let utilityID = Runtime.ExecutionContext.ID("utility") - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .executionContextCreated(Runtime.ExecutionContext( - id: mainID, - name: "Main Updated", - kind: .normal - )), - target: target - ) - try await waitUntil { mainContext.name == "Main Updated" } - #expect(context.executionContexts.first === mainContext) - - try await runtime.wire.emitRaw( - .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) - - try await runtime.wire.emitRaw( - .executionContextDestroyed(utilityID), - target: target - ) - try await waitUntil { - context.executionContexts.count == 1 && context.selectedContext === mainContext - } - #expect(context.executionContexts.first === mainContext) - - try await runtime.wire.emitRaw( - .executionContextsCleared(target: target.id), - target: target - ) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil - } - } -} - -@MainActor -@Test -func dataKitTestRuntimeScopeQuiescesPendingWorkAndReleasesOwners() async throws { - weak var weakWire: DataKitRawWireDriver? - weak var weakPeer: WebInspectorTestPeer? - weak var weakProxy: WebInspectorProxy? - var observations: DataKitRawWireDriver.Observations? - var observationWaiter: Task<[WebInspectorTestPeer.Command], Never>? - var pendingReply: Task? - - try await withDataKitTestRuntime { runtime in - weakWire = runtime.wire - weakPeer = runtime.peer - weakProxy = runtime.proxy - - let runtimeObservations = runtime.wire.observations - observations = runtimeObservations - observationWaiter = Task { - await runtimeObservations.waitForCommands( - method: "Test.neverReceived", - count: 1 - ) - } - try await waitUntil { - runtimeObservations.pendingWaiterCountForTesting == 1 - } - - _ = await runtime.wire.deferReply(to: "Page.reload") - let page = runtime.page - pendingReply = Task { - try await page.page.reload() - } - _ = await runtimeObservations.waitForCommands(method: "Page.reload", count: 1) - } - - let finishedObservationWaiter = try #require(observationWaiter) - #expect(await finishedObservationWaiter.value.isEmpty) - #expect(observations?.pendingWaiterCountForTesting == 0) - - let finishedReply = try #require(pendingReply) - await #expect(throws: WebInspectorProxyError.closed) { - try await finishedReply.value - } - - #expect(weakWire == nil) - #expect(weakPeer == nil) - #expect(weakProxy == nil) -} - -@MainActor -private func startContext( - runtime: DataKitTestRuntime, - 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() - try await enqueueStartupReplies(on: runtime.wire, 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: [String] { - [ - "Inspector.enable", - "Inspector.initialized", - "Runtime.enable", - "Network.enable", - "DOM.getDocument", - "Console.enable", - ] -} - -private var shutdownCommands: [String] { - [ - "Console.disable", - "Runtime.disable", - "Network.disable", - "Inspector.disable", - ] -} - -private func enqueueStartupReplies( - on backend: DataKitRawWireDriver, - document: DOM.Node = DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") -) async throws { - await enqueueDomainEnableReplies(on: backend) - await backend.respond( - to: "DOM.getDocument", - with: try domDocumentResult(document) - ) -} - -private func enqueueDomainEnableReplies(on backend: DataKitRawWireDriver) async { - await backend.respond(to: "Inspector.enable") - await backend.respond(to: "Inspector.initialized") - await backend.respond(to: "Runtime.enable") - await backend.respond(to: "Network.enable") - await backend.respond(to: "Console.enable") -} - -private func enqueueDomainDisableReplies(on backend: DataKitRawWireDriver) async { - await backend.respond(to: "Console.disable") - await backend.respond(to: "Runtime.disable") - await backend.respond(to: "Network.disable") - await backend.respond(to: "Inspector.disable") -} - -private func enqueueCSSStyleReplies(on backend: DataKitRawWireDriver) async throws { - await backend.respond( - to: "CSS.getMatchedStylesForNode", - with: try testJSONObject( - #"{"matchedCSSRules":[{"rule":{"selectorList":{"selectors":[{"text":".card"}],"text":".card"},"origin":"regular","style":{"styleId":{"styleSheetId":"style-1","ordinal":0},"cssProperties":[{"name":"display","value":"grid","text":"display: grid;"}],"cssText":"display: grid;"}}}]}"# - ) - ) - await backend.respond( - to: "CSS.getInlineStylesForNode", - with: try testJSONObject("{}") - ) - await backend.respond( - to: "CSS.getComputedStyleForNode", - with: try testJSONObject( - #"{"computedStyle":[{"name":"display","value":"grid"}]}"# - ) - ) -} - -private func matchedStylesCommandCount(on backend: DataKitRawWireDriver) -> Int { - backend.observations.commands - .filter { $0.method == "CSS.getMatchedStylesForNode" } - .count -} - -@MainActor -private func waitForStartupSubscribers( - runtime: DataKitTestRuntime, - target: WebInspectorTarget -) async throws { - _ = runtime - await target.waitForModelEventSubscriptions() -} - -private func emitFinishedRequest( - id: Network.Request.ID, - target: WebInspectorTarget, - backend: DataKitRawWireDriver -) async throws { - let targetID = target.pageBindingID ?? "page-main" - try await backend.emitTargetEvent( - targetID: targetID, - method: "Network.requestWillBeSent", - parameters: try testJSONObject( - """ - {"requestId":"\(id.rawValue)","request":{"url":"https://example.com/\(id.rawValue)","method":"GET","headers":{}},"type":"Fetch","timestamp":1} - """ - ) - ) - try await backend.emitTargetEvent( - targetID: targetID, - method: "Network.responseReceived", - parameters: try testJSONObject( - """ - {"requestId":"\(id.rawValue)","response":{"status":200,"mimeType":"text/plain"},"type":"Fetch","timestamp":2} - """ - ) - ) - try await backend.emitTargetEvent( - targetID: targetID, - method: "Network.loadingFinished", - parameters: try testJSONObject( - """ - {"requestId":"\(id.rawValue)","timestamp":3} - """ - ) - ) -} - -@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}}}"# - ) -} - -@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 -@discardableResult -private func seedFetchedResultsRequest( - _ requestID: String, - url: String, - timestamp: Double, - in context: WebInspectorContext -) -> NetworkRequest.ID { - context.seedNetworkRequest( - requestID: requestID, - url: url, - resourceTypeRawValue: "Fetch", - responseMIMEType: "text/plain", - responseStatus: 200, - responseStatusText: "OK", - timestamp: timestamp - ) -} - -@MainActor -private final class FetchedResultsTransactionRecorder { - private(set) var updates: [WebInspectorFetchedResultsUpdate] = [] - private(set) var transactions: [WebInspectorFetchedResultsTransaction] = [] - private(set) var reconfigureItemIDSets: [Set] = [] - - private var task: Task? - private var hasConsumedInitialUpdate = false - - init(stream: AsyncStream>) { - task = Task { @MainActor [weak self] in - for await update in stream { - self?.updates.append(update) - guard case let .transaction(_, transaction, reconfigureItemIDs) = update else { - self?.hasConsumedInitialUpdate = true - continue - } - self?.transactions.append(transaction) - self?.reconfigureItemIDSets.append(reconfigureItemIDs) - } - } - } - - func waitUntilStarted() async throws { - try await waitUntil { self.hasConsumedInitialUpdate } - } - - 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 { - 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 - } -} - -@MainActor -private func waitUntil( - timeout: Duration = .seconds(1), - condition: @escaping @MainActor @Sendable () -> Bool -) async throws { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while condition() == false { - if clock.now >= deadline { - throw TimedOut() - } - await Task.yield() - } -} - -@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() - } - await Task.yield() + return index == subsequence.endIndex } } diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 9c52030a..4f83a0a6 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -63,6 +63,289 @@ func modelDomainNormalizationAddsDOMOnlyWhenCSSRequiresIt() { #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 core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = 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")) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-new", + message: modelFeedInspectorInspectMessage(objectID: "before-new-activation") + )) + await modelFeedRespond(to: activate, core: core) + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-new", + message: modelFeedInspectorInspectMessage(objectID: "after-new-activation") + )) + 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() @@ -3172,6 +3455,99 @@ private func modelFeedExpectedEnableMethods( 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, diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift index 8aa36056..8149ad5b 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift @@ -56,6 +56,59 @@ func rawPeerRoutesFrameRequestNodeThroughCurrentPageDOMAgent() async throws { await runtime.close() } +@Test +func rawElementPickerResolvesInspectorEventThroughProductionCommandPath() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let eventTask = Task { + 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 + } + } + + 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) + + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Inspector.inspect", + parameters: try jsonObject( + #"{"object":{"type":"object","subtype":"node","objectId":"remote-node"},"hints":{}}"# + ) + ) + + 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"}"#) + ) + + 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) + + #expect(try await eventTask.value == DOM.Node.ID("selected-node")) + await runtime.close() +} + @Test func rawPeerPreservesScopedCSSRoutingAndStripsWireIdentifierScope() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() From 9b4eb577739dc5f984f309406e7890d72d298998 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:09:19 +0900 Subject: [PATCH 59/81] test(contract): migrate DataKit consumer stories --- .../ContractTestSupport.swift | 459 ++++++------------ .../StoryADataKitRawPeerTests.swift | 33 -- .../StoryADataKitImportOnlyContract.swift | 73 +-- 3 files changed, 191 insertions(+), 374 deletions(-) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index 62a0424c..ad068dd0 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -70,15 +70,6 @@ enum ContractTestSupport { ]) } - static func dataReceivedParameters() throws -> WebInspectorTestJSONObject { - try jsonObject([ - "requestId": "contract-request", - "dataLength": 7, - "encodedDataLength": 4, - "timestamp": 3, - ]) - } - static func loadingFinishedParameters() throws -> WebInspectorTestJSONObject { try jsonObject([ "requestId": "contract-request", @@ -93,16 +84,6 @@ enum ContractTestSupport { ]) } - static func consoleMessageParameters(text: String) throws -> WebInspectorTestJSONObject { - try jsonObject([ - "message": [ - "source": "javascript", - "level": "log", - "text": text, - ], - ]) - } - static func outerHTMLResult(_ html: String) throws -> WebInspectorTestJSONObject { try jsonObject(["outerHTML": html]) } @@ -151,22 +132,6 @@ 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() - } - } } struct TimedOut: Error {} @@ -180,77 +145,52 @@ actor ContractDataKitActor { let canRequestProperties: Bool } - nonisolated let inspectorContainer: WebInspectorContainer + 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: WebInspectorTestJSONObject? = nil, - sharesDomainLeases: Bool = false + document: WebInspectorTestJSONObject? = nil ) async throws -> WebInspectorTarget { - let documentResult: WebInspectorTestJSONObject - if let document { - documentResult = document - } else { - documentResult = try ContractTestSupport.documentResult() - } - - let context = modelContext() let target = try await runtime.proxy.waitForCurrentPage() - context.start() - - if sharesDomainLeases == false { - var command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "Inspector.enable") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "Inspector.initialized") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "Runtime.enable") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "Network.enable") - try await runtime.peer.reply(to: command) + let documentResult = try document ?? ContractTestSupport.documentResult() + let attachTask = Task { + try await context.attach(to: runtime.proxy, isolation: self) } - - var command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "DOM.getDocument") - try await runtime.peer.reply(to: command, with: documentResult) - - if sharesDomainLeases == false { - command = try await runtime.peer.commands.next() + var observedMethods: Set = [] + for _ in 0..<5 { + let command = try await runtime.peer.commands.next() commands.append(command) try #require(command.destination == .target("page-main")) - try #require(command.method == "Console.enable") - try await runtime.peer.reply(to: command) + 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) + } } - - try await ContractTestSupport.waitUntil { context.state == .attached } + #expect(observedMethods == [ + "CSS.enable", + "Network.enable", + "Console.enable", + "Runtime.enable", + "DOM.getDocument", + ]) + try await attachTask.value + #expect(context.state == .attached) return target } @@ -259,14 +199,7 @@ actor ContractDataKitActor { } 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 concreteRequests = try await context.networkRequests(matching: NetworkQuery( + let requests = try await context.networkRequests(matching: NetworkQuery( search: " contract ", resourceCategories: [.xhrFetch], methods: ["GET"], @@ -275,33 +208,28 @@ actor ContractDataKitActor { offset: 0, limit: 10 )) - let concreteConsole = try await context.consoleMessages(matching: ConsoleQuery( + let messages = try await context.consoleMessages(matching: ConsoleQuery( levels: [Console.Level(rawValue: "warning")], sort: .insertionDescending, section: .level, offset: 0, limit: 10 )) - #expect(requestResults.items.isEmpty) - #expect(consoleResults.items.isEmpty) - #expect(sectionedRequests.sections.isEmpty) - #expect(sectionedConsole.sections.isEmpty) - #expect(concreteRequests.items.isEmpty) - #expect(concreteConsole.items.isEmpty) - #expect(requestResults.snapshot.itemIDs.isEmpty) - #expect(consoleResults.snapshot.itemIDs.isEmpty) - _ = requestResults.updates() - _ = consoleResults.updates() - try await concreteRequests.update(NetworkQuery(sort: .requestTimeDescending)) - try await concreteConsole.update(ConsoleQuery(sort: .insertionAscending)) - #expect(context.state == .attached) - - let root = try #require(context.rootNode) + #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") @@ -310,17 +238,15 @@ actor ContractDataKitActor { _ = treeController.updates _ = treeController.revealRequests - context.select(root) - #expect(context.selectedNode === root) - context.select(nil) - context.selectContext(nil) + try context.selectDOMNode(root) + #expect(try context.selectedDOMNode === root) + try context.selectDOMNode(nil) await context.clearNetworkRequests() - #expect(context.selectedNode == nil) - #expect(context.selectedContext == nil) + #expect(try context.selectedDOMNode == nil) + #expect(try context.runtimeContexts.isEmpty) } func assertRawPeerDrivesDOMNetworkAndRuntimeContracts() async throws { - let context = modelContext() try await start(document: ContractTestSupport.documentResult( id: "contract-document", documentURL: "https://example.com/", @@ -332,111 +258,79 @@ actor ContractDataKitActor { method: "DOM.setChildNodes", parameters: ContractTestSupport.setChildNodesParameters() ) - - try await ContractTestSupport.waitUntil { - guard let root = context.rootNode, + 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") let copyHTMLTask = Task { - try await child.copyText(.html, isolation: self) + try await context.copyText(.html, for: child) } - var command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.destination == .target("page-main")) - try #require(command.method == "DOM.getOuterHTML") - try await runtime.peer.reply( - to: command, - with: ContractTestSupport.outerHTMLResult("
") + var command = try await replyNext( + expectedMethod: "DOM.getOuterHTML", + result: ContractTestSupport.outerHTMLResult( + "
" + ) ) #expect(try await copyHTMLTask.value == "
") - #expect(try await child.copyText(.selectorPath) == "main") let highlightTask = Task { - try await child.highlight(isolation: self) + try await context.highlightDOMNode(child) } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.highlightNode") - try await runtime.peer.reply(to: command) + command = try await replyNext(expectedMethod: "DOM.highlightNode") try await highlightTask.value - let hideHighlightTask = Task { - try await context.hideHighlight(isolation: self) + let hideTask = Task { + try await context.hideDOMHighlight() } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.hideHighlight") - try await runtime.peer.reply(to: command) - try await hideHighlightTask.value + command = try await replyNext(expectedMethod: "DOM.hideHighlight") + try await hideTask.value - let enablePickerTask = Task { - try await context.setElementPickerEnabled(true, isolation: self) + let enablePicker = Task { + try await context.setElementPickerEnabled(true) } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.setInspectModeEnabled") - try await runtime.peer.reply(to: command) - try await enablePickerTask.value - #expect(context.isElementPickerEnabled) - - let disablePickerTask = Task { - try await context.setElementPickerEnabled(false, isolation: self) + _ = 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) } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.setInspectModeEnabled") - try await runtime.peer.reply(to: command) - try await disablePickerTask.value - #expect(context.isElementPickerEnabled == false) + _ = try await replyNext(expectedMethod: "DOM.setInspectModeEnabled") + _ = try await replyNext(expectedMethod: "Inspector.disable") + try await disablePicker.value + #expect(try context.isElementPickerEnabled == false) let deleteTask = Task { - try await child.delete(isolation: self) + try await context.removeDOMNodes([child]).appliedNodeIDs } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.removeNode") - try await runtime.peer.reply(to: command) - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "DOM.markUndoableState") - try await runtime.peer.reply(to: command) - try await deleteTask.value + _ = try await replyNext(expectedMethod: "DOM.removeNode") + _ = try await replyNext(expectedMethod: "DOM.markUndoableState") + let deletedNodeIDs = try await deleteTask.value + #expect(deletedNodeIDs == [child.id]) let reloadTask = Task { - try await context.reloadPage(isolation: self) + try await context.reload() } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Page.reload") - try await runtime.peer.reply(to: command) + _ = try await replyNext(expectedMethod: "Page.reload") try await reloadTask.value - #expect(commands.contains { $0.method == "DOM.getOuterHTML" }) - #expect(commands.contains { $0.method == "DOM.highlightNode" }) - #expect(commands.contains { $0.method == "DOM.hideHighlight" }) - #expect(commands.contains { $0.method == "DOM.setInspectModeEnabled" }) - #expect(commands.contains { $0.method == "DOM.removeNode" }) - #expect(commands.contains { $0.method == "DOM.markUndoableState" }) - #expect(commands.contains { $0.method == "Page.reload" }) - - let requestResults: WebInspectorFetchedResults = context.fetchedResults() + let requestResults = try await context.networkRequests() try await runtime.peer.emitTargetEvent( targetID: "page-main", method: "Network.requestWillBeSent", @@ -447,154 +341,99 @@ actor ContractDataKitActor { method: "Network.responseReceived", parameters: ContractTestSupport.responseReceivedParameters() ) - try await runtime.peer.emitTargetEvent( - targetID: "page-main", - method: "Network.dataReceived", - parameters: ContractTestSupport.dataReceivedParameters() - ) try await runtime.peer.emitTargetEvent( targetID: "page-main", method: "Network.loadingFinished", parameters: ContractTestSupport.loadingFinishedParameters() ) - - let requests: WebInspectorFetchedResults = context.fetchedResults() - try await ContractTestSupport.waitUntil { - requests.items.first?.state == .finished + try await ContractTestSupport.waitUntil(isolation: self) { + requestResults.items.first?.state == .finished } - 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(requestResults.snapshot.itemIDs == [requestModel.id]) + 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 { - await requestModel.fetchResponseBody(isolation: self) + let body = try await context.responseBody(for: request, isolation: self) + return BodySnapshot( + phase: body.phase, + text: body.text, + isBase64Encoded: body.isBase64Encoded + ) } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Network.getResponseBody") - try await runtime.peer.reply( - to: command, - with: ContractTestSupport.responseBodyResult( + _ = try await replyNext( + expectedMethod: "Network.getResponseBody", + result: ContractTestSupport.responseBodyResult( "{\"ok\":true}", base64Encoded: false ) ) - await bodyTask.value - #expect(requestModel.responseBody.phase == .loaded) - #expect(requestModel.responseBody.text == "{\"ok\":true}") - #expect(requestModel.responseBody.isBase64Encoded == false) + let body = try await bodyTask.value + #expect(body.phase == .loaded) + #expect(body.text == "{\"ok\":true}") + #expect(body.isBase64Encoded == false) let evaluationTask = Task { - let evaluation = try await context.evaluate( - "document.title", - isolation: self - ) - return EvaluationSnapshot( - isException: evaluation.isException, - kind: evaluation.object.kind, - value: evaluation.object.value, - description: evaluation.object.description, - canRequestProperties: evaluation.object.canRequestProperties - ) + 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 + ) + } } - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Runtime.evaluate") - try await runtime.peer.reply( - to: command, - with: ContractTestSupport.evaluationResult() + _ = 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) - } - func emitConsoleMessage(text: String) async throws { - try await runtime.peer.emitTargetEvent( - targetID: "page-main", - method: "Console.messageAdded", - parameters: ContractTestSupport.consoleMessageParameters(text: text) - ) + _ = command } - func waitForConsoleMessage(text: String) async throws { - let context = modelContext() - let messages: WebInspectorFetchedResults = context.fetchedResults() - try await ContractTestSupport.waitUntil { - messages.items.contains { $0.text == text } + func close() async throws { + let closeTask = Task { + await context.close() } - } - - private func modelContext() -> WebInspectorContext { - if let context { - return context + for expectedMethod in [ + "Runtime.disable", + "Console.disable", + "Network.disable", + "CSS.disable", + ] { + _ = try await replyNext(expectedMethod: expectedMethod) } - let context = WebInspectorContext(inspectorContainer, isolation: self) - self.context = context - return context - } - - private func requirePersistentModel(_ model: Model) { - #expect(Set([model]).contains(model)) + await closeTask.value + #expect(context.state == .closed) } - func stopContext(expectsDomainDisableCommands: Bool = true) async throws { - guard let context else { - return - } - let stopTask = Task { - await context.stop(isolation: self) - } - - if expectsDomainDisableCommands { - var command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Console.disable") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Runtime.disable") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Network.disable") - try await runtime.peer.reply(to: command) - - command = try await runtime.peer.commands.next() - commands.append(command) - try #require(command.method == "Inspector.disable") - try await runtime.peer.reply(to: command) - } - - await stopTask.value - #expect(context.state == .detached) - #expect(context.teardownError == nil) + @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 } - func close() async throws { - try await stopContext() - await inspectorContainer.close() + private func requirePersistentModel( + _ model: Model + ) { + #expect(Set([model]).contains(model)) } } diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift index 9cad19fe..09b6d726 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift @@ -10,36 +10,3 @@ func rawPeerDrivesDataKitDOMNetworkAndRuntimeContracts() async throws { try await owner.assertRawPeerDrivesDOMNetworkAndRuntimeContracts() try 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) - - try await firstOwner.start() - try await secondOwner.start(sharesDomainLeases: true) - - var commands = await firstOwner.observedCommands() + secondOwner.observedCommands() - #expect(commands.filter { $0.method == "Runtime.enable" }.count == 1) - #expect(commands.filter { $0.method == "Network.enable" }.count == 1) - #expect(commands.filter { $0.method == "Console.enable" }.count == 1) - - try await firstOwner.stopContext(expectsDomainDisableCommands: false) - - commands = await firstOwner.observedCommands() + secondOwner.observedCommands() - #expect(commands.contains { $0.method == "Runtime.disable" } == false) - #expect(commands.contains { $0.method == "Network.disable" } == false) - #expect(commands.contains { $0.method == "Console.disable" } == false) - - try await secondOwner.emitConsoleMessage(text: "second-context-still-live") - try await secondOwner.waitForConsoleMessage(text: "second-context-still-live") - - try await secondOwner.close() - - commands = await firstOwner.observedCommands() + secondOwner.observedCommands() - #expect(commands.filter { $0.method == "Runtime.disable" }.count == 1) - #expect(commands.filter { $0.method == "Network.disable" }.count == 1) - #expect(commands.filter { $0.method == "Console.disable" }.count == 1) -} diff --git a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift index 07e453e5..6ca2d595 100644 --- a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift +++ b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift @@ -7,35 +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 concreteRequests = try await context.networkRequests(matching: NetworkQuery( + 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 concreteMessages = try await context.consoleMessages(matching: ConsoleQuery( + 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.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 await context.treeController() + let treeController = try context.domTree let treeSnapshot: DOMTreeSnapshot = treeController.snapshot _ = treeSnapshot.rootNodeID _ = treeSnapshot.nodesByID.values.first?.attributeList.first?.value @@ -45,22 +43,24 @@ 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 = requests.snapshot let requestTransaction = WebInspectorFetchedResultsTransaction( @@ -70,20 +70,31 @@ private actor DataKitImportOnlyActor { ) _ = requests.revision _ = requests.updates() - _ = concreteRequests.snapshot - try await concreteRequests.update(NetworkQuery()) + 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 + _ = messages.sections.first?.id _ = messages.snapshot _ = messages.revision _ = messages.updates() - _ = concreteMessages.snapshot - try await concreteMessages.update(ConsoleQuery()) - _ = try await context.evaluate("1 + 1").object.description + 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 From d9640f95a09d46f0e3a84d08748a67742c59924a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:24:04 +0900 Subject: [PATCH 60/81] fix(ui): use public DOM selection in preview --- .../Element/DOMElementViewController+Preview.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift index 98e284d8..2b07b2b5 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift @@ -17,7 +17,7 @@ private enum DOMElementViewControllerPreview { static func makeViewController() -> UINavigationController { 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)) From ddab435eb8dbc2c9375fe231ce4c60d2a5d9d7b6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:27:56 +0900 Subject: [PATCH 61/81] test: align model contracts with scoped targets --- .../ContractTestSupport.swift | 5 +-- .../RuntimeStateStoreTests.swift | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index ad068dd0..43814247 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -161,11 +161,9 @@ actor ContractDataKitActor { commands = [] } - @discardableResult func start( document: WebInspectorTestJSONObject? = nil - ) async throws -> WebInspectorTarget { - let target = try await runtime.proxy.waitForCurrentPage() + ) async throws { let documentResult = try document ?? ContractTestSupport.documentResult() let attachTask = Task { try await context.attach(to: runtime.proxy, isolation: self) @@ -191,7 +189,6 @@ actor ContractDataKitActor { ]) try await attachTask.value #expect(context.state == .attached) - return target } func observedCommands() -> [WebInspectorTestPeer.Command] { diff --git a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift index 43d2d790..bd557912 100644 --- a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift +++ b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift @@ -12,7 +12,8 @@ private actor RuntimeStateStoreIsolationProbe { name: "Custom actor", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) return (store.executionContexts.count, store.selectedContext?.name) } @@ -47,7 +48,8 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "First", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) let first = try #require(store.executionContexts.first) #expect(store.selectedContext === first) @@ -58,7 +60,8 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "First updated", kind: .user )), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) #expect(store.executionContexts == [first]) #expect(first.name == "First updated") @@ -70,7 +73,8 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { name: "Second", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) let second = try #require(store.executionContexts.last) store.select(second) @@ -78,7 +82,8 @@ func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { store.apply( .executionContextDestroyed(secondID), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) #expect(store.executionContexts == [first]) #expect(store.selectedContext === first) @@ -203,7 +208,8 @@ func runtimeStateStoreTargetClearPreservesOtherTargetState() throws { name: "Page", kind: .normal )), - sourceTargetID: pageTargetID + sourceTargetID: pageTargetID, + isCurrentPageTarget: true ) store.apply( .executionContextCreated(Runtime.ExecutionContext( @@ -211,7 +217,8 @@ func runtimeStateStoreTargetClearPreservesOtherTargetState() throws { name: "Frame", kind: .normal )), - sourceTargetID: frameTargetID + sourceTargetID: frameTargetID, + isCurrentPageTarget: false ) let pageContext = try #require(store.executionContexts.first) let frameContext = try #require(store.executionContexts.last) @@ -243,8 +250,9 @@ func runtimeStateStoreTargetClearPreservesOtherTargetState() throws { ).object store.apply( - .executionContextsCleared(target: frameTargetID), - sourceTargetID: pageTargetID + .executionContextsCleared, + sourceTargetID: frameTargetID, + isCurrentPageTarget: false ) #expect(store.executionContexts.count == 1) @@ -269,14 +277,16 @@ func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() th name: "Before", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page") + 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") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) store.apply( .executionContextCreated(Runtime.ExecutionContext( @@ -284,7 +294,8 @@ func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() th name: "After", kind: .normal )), - sourceTargetID: WebInspectorTarget.ID("page") + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true ) let groupID = store.createGroupID() From ff7c6bb13b27a89494e6065307e76750dd09f524 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:28:24 +0900 Subject: [PATCH 62/81] feat(testing): add ready DataKit scenarios --- ContractTests/Package.swift | 1 + .../StoryADataKitTestingContractTests.swift | 66 ++ Docs/MIGRATION.md | 27 + Docs/WebInspectorKitsArchitecture.md | 53 +- Package.swift | 14 + README.md | 27 + .../WebInspectorDataKitTestRuntime.swift | 720 ++++++++++++++++++ .../WebInspectorDataKitTesting.md | 50 ++ ...bInspectorDataKitTestingProductTests.swift | 107 +++ 9 files changed, 1056 insertions(+), 9 deletions(-) create mode 100644 ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift create mode 100644 Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift create mode 100644 Sources/WebInspectorDataKitTesting/WebInspectorDataKitTesting.docc/WebInspectorDataKitTesting.md create mode 100644 Tests/WebInspectorDataKitTests/WebInspectorDataKitTestingProductTests.swift diff --git a/ContractTests/Package.swift b/ContractTests/Package.swift index 1aa9be7b..7a6bb182 100644 --- a/ContractTests/Package.swift +++ b/ContractTests/Package.swift @@ -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/StoryADataKitTestingContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift new file mode 100644 index 00000000..b5616f34 --- /dev/null +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift @@ -0,0 +1,66 @@ +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") + + 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/Docs/MIGRATION.md b/Docs/MIGRATION.md index 32406c30..d11f7fdd 100644 --- a/Docs/MIGRATION.md +++ b/Docs/MIGRATION.md @@ -137,6 +137,33 @@ Apply these mappings to other tests: 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 index 2d44559f..9c5e02c3 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -155,13 +155,15 @@ External source evidence was read at fixed local revisions: 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 a non-Sendable context stored by one actor is -rejected when sent to another; `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. The last limitation is -why the design permits one narrow private unchecked weak bridge and no other -unchecked model ownership. +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 @@ -1828,6 +1830,37 @@ 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 @@ -2133,8 +2166,10 @@ Tests are added before each owner is replaced. Required cases: 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 a context, identity, or result - cannot cross to another actor, while its Sendable snapshots and deltas can. + 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: diff --git a/Package.swift b/Package.swift index e31c52f3..2fcf7490 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ @@ -184,6 +197,7 @@ let package = Package( name: "WebInspectorDataKitTests", dependencies: [ "WebInspectorDataKit", + "WebInspectorDataKitTesting", "WebInspectorProxyKitTesting", "WebInspectorProxyKit", "WebInspectorTestSupport" diff --git a/README.md b/README.md index fe57dbc3..268f52b5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ 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 to drive ProxyKit's production connection path from a concrete raw WebKit peer in tests. | @@ -120,6 +121,31 @@ 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/) @@ -137,6 +163,7 @@ 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/ Production-path raw peer test runtime. WebInspectorUI*/ Internal UIKit implementation targets. diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift new file mode 100644 index 00000000..78ac5141 --- /dev/null +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift @@ -0,0 +1,720 @@ +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 + } + 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.selectedNodeID == expectedNodeID, + !status.isElementPickerEnabled { + 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) { + 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 "CSS.enable", "Console.enable", "Runtime.enable", + "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 +} 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/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).") + } +} From 8d91f14e1f68be8c5f7d40ddda9733439a9ad340 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:31:17 +0900 Subject: [PATCH 63/81] fix(testing): reject missing picker fixtures --- .../StoryADataKitTestingContractTests.swift | 8 ++++++++ .../WebInspectorDataKitTestRuntime.swift | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift index b5616f34..72e19281 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift @@ -33,6 +33,14 @@ func readyDataKitScenarioIsUsableFromAConsumerPackage() async throws { try await runtime.replacePage(with: .init()) #expect(try runtime.model.rootDOMNode?.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) diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift index 78ac5141..a5c3c4a4 100644 --- a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift @@ -230,7 +230,7 @@ public final class WebInspectorDataKitTestRuntime { guard !isClosed else { throw RuntimeError.closed } - await driver.registerPickerSelection( + try await driver.registerPickerSelection( remoteObjectID: remoteObjectID, nodeID: nodeID ) @@ -255,8 +255,10 @@ public final class WebInspectorDataKitTestRuntime { while let status = await updates.next() { switch status.state { case .attached: - if status.selectedNodeID == expectedNodeID, - !status.isElementPickerEnabled { + if !status.isElementPickerEnabled { + guard status.selectedNodeID == expectedNodeID else { + throw RuntimeError.selectedNodeMissing(nodeID) + } guard let node = try model.selectedDOMNode else { throw RuntimeError.selectedNodeMissing(nodeID) } @@ -415,7 +417,10 @@ private actor ScenarioDriver { } } - func registerPickerSelection(remoteObjectID: String, nodeID: String) { + func registerPickerSelection(remoteObjectID: String, nodeID: String) throws { + guard document.children.contains(where: { $0.containsNode(id: nodeID) }) else { + throw WebInspectorDataKitTestRuntime.RuntimeError.selectedNodeMissing(nodeID) + } pickerSelections[remoteObjectID] = nodeID } @@ -718,3 +723,9 @@ 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) }) + } +} From 3f5c3bf42a3daffbf4593a077addf8866a70cd6d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:31:37 +0900 Subject: [PATCH 64/81] fix(ui): own DOM style hydration and mutations --- .../Element/DOMElementStylePropertyView.swift | 28 ++++- .../Element/DOMElementViewController.swift | 101 +++++++++++++++--- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift index 40a66244..0d1ac741 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift @@ -5,12 +5,13 @@ 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? override package init(frame: CGRect) { super.init(frame: frame) @@ -26,6 +27,11 @@ package final class DOMElementStylePropertyView: UIView { property: CSSStyleProperty, onToggle: ToggleAction? = nil ) { + if self.property?.id != property.id { + // A submitted mutation is committed user work and outlives cell + // reuse. Drop only this view's completion handle. + toggleTask = nil + } render(property: property, onToggle: onToggle) } @@ -39,6 +45,9 @@ package final class DOMElementStylePropertyView: UIView { } package func clear() { + // Do not cancel an accepted mutation when the collection view reuses + // its presentation cell. + toggleTask = nil property = nil toggleAction = nil declarationTextView.attributedText = nil @@ -131,13 +140,26 @@ 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?.id == property.id else { + return + } + toggleTask = nil + if let currentProperty = self.property { + renderToggleState(from: currentProperty) + } } } private func canToggle(_ property: CSSStyleProperty) -> Bool { - property.isEditable && toggleAction != nil + property.isEditable && toggleAction != nil && toggleTask == nil } private func declarationText(for property: CSSStyleProperty) -> NSAttributedString { diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index 36d37665..a66892b9 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -9,6 +9,9 @@ import UIKit package final class DOMElementViewController: UICollectionViewController { 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 observedSelectedNodeObjectID: ObjectIdentifier? private var hasBoundSelectedNode = false @@ -45,7 +48,7 @@ package final class DOMElementViewController: UICollectionViewController { #if DEBUG resolveStyleRenderWaitersForTesting(result: false) #endif - context.setStyleHydrationActive(false) + styleHydrationTask?.cancel() statusTask?.cancel() selectedStylesObservation?.cancel() } @@ -67,11 +70,15 @@ package final class DOMElementViewController: UICollectionViewController { override package func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) - context.setStyleHydrationActive(true) + isStyleHydrationActive = true + if let selectedNode = try? context.selectedDOMNode { + hydrateStylesIfNeeded(for: selectedNode, retryFailure: true) + } } override package func viewDidDisappear(_ animated: Bool) { - context.setStyleHydrationActive(false) + isStyleHydrationActive = false + cancelStyleHydration() super.viewDidDisappear(animated) } @@ -181,13 +188,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 +206,7 @@ package final class DOMElementViewController: UICollectionViewController { } hasBoundSelectedNode = true observedSelectedNodeObjectID = nodeObjectID + cancelStyleHydration() selectedStylesObservation?.cancel() guard let node else { selectedStylesObservation = nil @@ -215,8 +223,69 @@ package final class DOMElementViewController: UICollectionViewController { return } self.renderSelectedStyles(node.elementStyles) + if node.elementStyles?.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 { + // CSSStyles owns and publishes the failed phase. + } + 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 } /// Renders the selected node's styles. Runs inside the observation @@ -347,12 +416,20 @@ package final class DOMElementViewController: UICollectionViewController { } private func toggleAction() -> DOMElementStylePropertyView.ToggleAction? { - return { [weak context] propertyID, enabled in - context?.requestSetCSSProperty( - propertyID, - enabled: enabled, - options: .automatic - ) ?? 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 { + return false + } } } @@ -392,7 +469,7 @@ package final class DOMElementViewController: UICollectionViewController { } package func renderCurrentStylesForTesting() { - renderSelectedStyles(context.selectedNode?.elementStyles) + renderSelectedStyles((try? context.selectedDOMNode)?.elementStyles) } private func finishStyleRenderForTesting() { From 174fcc6d980f9ef4a63dc34eac638ba579a62b7f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:33:15 +0900 Subject: [PATCH 65/81] fix(ui): report presentation cleanup failures --- .../Containers/WebInspectorSession.swift | 30 ++++++++++++++++--- .../WebInspectorViewController.swift | 14 ++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift index 0912599d..39f49efb 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -166,14 +166,36 @@ public final class WebInspectorSession { } /// Disables transient page interaction without tearing down the connection. - package func suspendBackendInteraction() async { + package func suspendBackendInteraction() async throws { guard model.state == .attached else { return } - if (try? model.isElementPickerEnabled) == true { - try? await model.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 model.hideDOMHighlight() } private func startPageUserInterfaceStyleObservation( diff --git a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift index 85eef0d8..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 @@ -315,7 +321,13 @@ public final class WebInspectorViewController: UIViewController { if automaticallyDetachesOnDismiss { await session.detach() } else { - await session.suspendBackendInteraction() + do { + try await session.suspendBackendInteraction() + } catch { + lifecycleLogger.error( + "Root presentation cleanup failed: \(String(describing: error), privacy: .public)" + ) + } } } } From 34431ab4b9bf605c82cf69f3e506c3b1172e47d6 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:37:30 +0900 Subject: [PATCH 66/81] fix(testing): allow document picker fixtures --- .../StoryADataKitTestingContractTests.swift | 4 ++++ .../WebInspectorDataKitTestRuntime.swift | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift index 72e19281..42bb68c3 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift @@ -33,6 +33,10 @@ func readyDataKitScenarioIsUsableFromAConsumerPackage() async throws { 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.") diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift index a5c3c4a4..2e8c32b5 100644 --- a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift @@ -418,7 +418,8 @@ private actor ScenarioDriver { } func registerPickerSelection(remoteObjectID: String, nodeID: String) throws { - guard document.children.contains(where: { $0.containsNode(id: nodeID) }) else { + guard document.id == nodeID + || document.children.contains(where: { $0.containsNode(id: nodeID) }) else { throw WebInspectorDataKitTestRuntime.RuntimeError.selectedNodeMissing(nodeID) } pickerSelections[remoteObjectID] = nodeID From 8a699d54afa33730ac8eefc3196f83d21017cc7c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:40:50 +0900 Subject: [PATCH 67/81] test(ui): migrate stable model fixtures --- .../DOMContainerTests.swift | 98 ++++----- ...OMElementStylePresentationStateTests.swift | 2 +- .../DOMTreeMenuModelTests.swift | 8 +- .../DOMTreeTextViewTests.swift | 10 +- .../NetworkDetailViewControllerTests.swift | 200 ++++++++++++------ .../NetworkPanelModelTests.swift | 68 ++---- .../ParentContainerTests.swift | 153 ++++++-------- 7 files changed, 271 insertions(+), 268 deletions(-) diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index dfffcc00..d0c7a410 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -288,7 +288,7 @@ struct DOMContainerTests { } #expect(didRenderBodyRows) - context.select(input) + try context.selectDOMNode(input) applyBodyStyles( to: context, selector: "input", @@ -324,7 +324,7 @@ struct DOMContainerTests { window.layoutIfNeeded() #expect(didRenderBodyRows) - 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 @@ -409,7 +409,7 @@ struct DOMContainerTests { #expect(input.elementStyles?.phase == .loading) #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 +764,7 @@ struct DOMContainerTests { } @Test - func elementStylePropertyViewSendsToggleActionWithImmediateControlFeedback() { + func elementStylePropertyViewSendsToggleActionWithImmediateControlFeedback() async { let propertyID = CSSStyleProperty.ID("test-style:0") let property = CSSStyleProperty( id: propertyID, @@ -777,9 +777,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 +789,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,8 +900,8 @@ 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) @@ -922,8 +929,8 @@ struct DOMContainerTests { @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) @@ -942,8 +949,8 @@ 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) @@ -966,8 +973,8 @@ 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) @@ -997,8 +1004,8 @@ struct DOMContainerTests { @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) @@ -1033,8 +1040,8 @@ struct DOMContainerTests { @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) @@ -1193,8 +1200,8 @@ struct DOMContainerTests { #expect(selectionHighlightNodeIDs.last == "input") let hoverFixture = try await makeLiveDOMContext() - let input = try #require(hoverFixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - hoverFixture.context.select(input) + let input = try #require(try hoverFixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try hoverFixture.context.selectDOMNode(input) let hoverViewController = DOMTreeViewController(context: hoverFixture.context) let hoverWindow = showInWindow(hoverViewController, useUIKitVisibility: false) defer { hoverWindow.isHidden = true } @@ -1378,14 +1385,14 @@ struct DOMContainerTests { }) } - private func makeWebInspectorContext() -> WebInspectorContext { - WebInspectorContext.preview(isolation: MainActor.shared) + private func makeWebInspectorContext() -> WebInspectorModelContext { + WebInspectorModelContext.preview() } private struct LiveDOMContextFixture { var runtime: WebInspectorProxyTestRuntime var wire: WebInspectorRawWireDriver - var context: WebInspectorContext + var context: WebInspectorModelContext } private func makeLiveDOMContext(document: DOM.Node? = nil) async throws -> LiveDOMContextFixture { @@ -1393,10 +1400,9 @@ struct DOMContainerTests { let wire = WebInspectorRawWireDriver(peer: runtime.peer) await wire.start() try await enqueueLiveStartupReplies(on: wire, document: document ?? documentNode()) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let didAttach = await waitForAttachedState(in: context) - try #require(didAttach) + 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) } @@ -1404,36 +1410,20 @@ struct DOMContainerTests { on wire: WebInspectorRawWireDriver, document: DOM.Node ) async throws { - await wire.respond(to: "Inspector.enable") - await wire.respond(to: "Inspector.initialized") - await wire.respond(to: "Runtime.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.respond(to: "Console.enable") await wire.fail( "CSS.getMatchedStylesForNode", message: "Styles are intentionally unavailable in the DOM navigation fixture." ) } - 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 - } - private func waitForDOMRedoAvailability(_ isAvailable: Bool, undoManager: UndoManager?) async -> Bool { await DOMDeletionUndoRegistration.waitForRedoAvailabilityForTesting(isAvailable, on: undoManager) } @@ -1451,27 +1441,27 @@ struct DOMContainerTests { 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, @@ -1531,7 +1521,7 @@ struct DOMContainerTests { } private func applyInheritedVariableStyles( - to context: WebInspectorContext, + to context: WebInspectorModelContext, bodyColorValue: String = "var(--foreground)", foregroundValue: String = "var(--palette-primary)", additionalBodyProperties: [PropertySpec] = [], diff --git a/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift b/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift index fbea57b6..0322a474 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 { 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 abac8644..4f7b486c 100644 --- a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift @@ -1532,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 { @@ -1560,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 92a5e408..8157e237 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 @@ -1225,79 +1227,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 = try await NetworkPanelModel.make(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 = try await NetworkPanelModel.make(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) + #expect(didRenderFailure) + let failedPhase = request.responseBody.phase - 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 + 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 @@ -2012,12 +2033,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] = [:], @@ -2072,7 +2136,7 @@ struct NetworkDetailViewControllerTests { } private func applyRequestWithoutResponse( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, requestHeaders: [String: String] = [:], @@ -2098,7 +2162,7 @@ struct NetworkDetailViewControllerTests { } private func applyResponseReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, responseHeaders: [String: String], @@ -2124,7 +2188,7 @@ struct NetworkDetailViewControllerTests { } private func applyDataReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, dataLength: Int, encodedDataLength: Int, @@ -2141,7 +2205,7 @@ struct NetworkDetailViewControllerTests { } private func applyLoadingFinished( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, timestamp: Double ) async { @@ -2156,7 +2220,7 @@ struct NetworkDetailViewControllerTests { } private func applyResponseBody( - to context: WebInspectorContext, + to context: WebInspectorModelContext, request: NetworkRequest, body: String, base64Encoded: Bool = false diff --git a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift index 0d91e75b..150d833d 100644 --- a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift +++ b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift @@ -104,7 +104,7 @@ func queryScheduledAfterClearWaitsForClearAndRemainsActive() async throws { model.setSearchText("new-endpoint") await model.waitForQueryUpdates() - #expect(context.registeredRequest(for: clearedRequestID) == nil) + #expect(try context.networkRequest(id: clearedRequestID) == nil) #expect(model.query.search == "new-endpoint") #expect(model.appliedQueryRevision == model.queryRevision) #expect(model.displayRequestIDs.isEmpty) @@ -178,7 +178,7 @@ func clearAvailabilityUsesUnfilteredRequestsWhenFiltersHideEveryRequest() async #expect(model.hasClearableRequests == false) #expect(await observedValues.waitUntilValue(false)) - #expect(context.registeredRequest(for: requestID) == nil) + #expect(try context.networkRequest(id: requestID) == nil) } @Test @@ -193,7 +193,7 @@ func selectedRequestUsesUnfilteredContextWhenFiltersHideRequest() async throws { mimeType: "text/javascript", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) @@ -217,7 +217,7 @@ func selectedRequestInvalidatesWhenUnfilteredRequestDisappears() async throws { mimeType: "text/javascript", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) + 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") @@ -270,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) } @@ -296,8 +296,8 @@ func requestDisplayUsesEncodingFallbackForURLDerivedLabelsAndFilters() async thr timestamp: 2 ) let model = try await NetworkPanelModel.make(context: context) - let spacedURLRequest = try #require(context.registeredRequest(for: spacedURLRequestID)) - let invalidEscapeRequest = try #require(context.registeredRequest(for: invalidEscapeRequestID)) + 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") @@ -565,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) @@ -585,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) @@ -675,7 +675,7 @@ func requestFileTypeAndSearchUpdateWhenRawMIMETypeAppears() async throws { timestamp: 1.1 ) let model = try await NetworkPanelModel.make(context: context) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.fileTypeLabel == "xhr") model.setSearchText("json") @@ -829,7 +829,7 @@ func displayRequestIDsUseDataKitClassificationForMediaFiltering() async throws { 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") @@ -857,7 +857,7 @@ func clearRequestsClearsSelectionButPreservesDisplayCriteria() async throws { 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() @@ -865,46 +865,20 @@ func clearRequestsClearsSelectionButPreservesDisplayCriteria() async throws { #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 = try await NetworkPanelModel.make(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", @@ -941,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", @@ -963,7 +937,7 @@ private func applyPendingRequest( @MainActor private func applyRedirectRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, method: String, @@ -985,7 +959,7 @@ private func applyRedirectRequest( @MainActor private func applyResponseReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, resourceType: Network.ResourceType, @@ -1015,7 +989,7 @@ private func applyResponseReceived( @MainActor private func applyDataReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, dataLength: Int, encodedDataLength: Int, @@ -1033,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 d4d02d57..9f96f000 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -70,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, @@ -89,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, @@ -111,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, @@ -145,33 +145,29 @@ 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 + makeProxy: { [self] in firstAttachStarted.open() await firstAttachGate.waiter.wait() - return try await makeFakeContainer() + return try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) } 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) - firstAttachGate.open() do { try await firstAttach.value @@ -179,7 +175,8 @@ struct ParentContainerTests { } 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) @@ -189,16 +186,17 @@ struct ParentContainerTests { @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 + makeProxy: { [self] in attachStarted.open() await attachGate.waiter.wait() - return try await makeFakeContainer() + return try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) @@ -206,8 +204,6 @@ struct ParentContainerTests { await attachStarted.waiter.wait() await session.detach() - let detachedContext = session.context - attachGate.open() do { try await attachTask.value @@ -215,74 +211,41 @@ struct ParentContainerTests { } 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 attachAdvancesContextEpochAndEvictsRootPresentationContent() async throws { - let session = makeSessionWithNoOpAttachment() + 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 initialContextRevision = session.interface.contextBoundContentRevision - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .dom, + let stableModel = session.model + let initialContent = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(tab.id), session: session, contentStore: contentStore, hostLayout: .compact ) - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - contentStore: contentStore, - hostLayout: .regular - ) - #expect(contentStore.contentCountForTesting > 0) + let initialContentCount = contentStore.contentCountForTesting - var installedContext: WebInspectorContext? - try await session.attachForTesting { - let container = try await makeFakeContainer() - installedContext = container.mainContext - return container - } - - let context = try #require(installedContext) - #expect(session.context === context) - #expect(session.interface.contextBoundContentRevision > initialContextRevision) - contentStore.prepare(for: session.interface.contextBoundContentRevision) - #expect(contentStore.contentCountForTesting == 0) - } + try await attach(session) - @Test - func installingDataContextAdvancesContextEpochAndEvictsRootPresentationContent() { - let session = makeSessionWithNoOpAttachment() - let viewController = WebInspectorViewController(session: session) - let contentStore = viewController.presentationContentStoreForTesting - let initialContextRevision = session.interface.contextBoundContentRevision - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .dom, + let contentAfterAttach = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(tab.id), session: session, contentStore: contentStore, hostLayout: .compact ) - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - contentStore: contentStore, - hostLayout: .regular - ) - #expect(contentStore.contentCountForTesting > 0) - let context = makeContext() - session.installDataContext(context) - - #expect(session.context === context) - #expect(session.interface.contextBoundContentRevision > initialContextRevision) - contentStore.prepare(for: session.interface.contextBoundContentRevision) - #expect(contentStore.contentCountForTesting == 0) + #expect(session.model === stableModel) + #expect(session.model.state == .attached) + #expect(contentAfterAttach === initialContent) + #expect(contentStore.contentCountForTesting == initialContentCount) } @Test @@ -483,7 +446,7 @@ struct ParentContainerTests { await factoryRelease.waiter.wait() return model } - weak var retainedStore = contentStore + weak let retainedStore = contentStore let resourceViewController = try #require(contentStore).networkViewController( context: context, contextEpoch: 1 @@ -505,7 +468,7 @@ struct ParentContainerTests { func networkResourceStoreDeinitSynchronouslyRetiresReadyBackstop() async throws { let context = makeContext() var contentStore: PresentationContentStore? = PresentationContentStore() - weak var retainedStore = contentStore + weak let retainedStore = contentStore let resourceViewController = try #require(contentStore).networkViewController( context: context, contextEpoch: 1 @@ -553,7 +516,7 @@ struct ParentContainerTests { } @Test - func compactHostRebuildsActiveTabsWhenDataContextChanges() async throws { + func compactHostRebuildsActiveTabsWhenContentRevisionChanges() async throws { let session = makeSessionWithNoOpAttachment() let viewController = WebInspectorViewController(session: session) viewController.horizontalSizeClassOverrideForTesting = .compact @@ -563,7 +526,7 @@ struct ParentContainerTests { let initialTabIdentities = initialTabs.map(ObjectIdentifier.init) #expect(initialTabs.isEmpty == false) - session.installDataContext(makeContext()) + session.interface.contextDidChange() let didRebuildTabs = await waitUntilCompactHostRendered(in: compactHost) { let rebuiltTabs = compactHost.currentUITabsForTesting @@ -574,7 +537,7 @@ struct ParentContainerTests { } @Test - func regularHostRebuildsVisibleContentWhenDataContextChanges() async throws { + func regularHostRebuildsVisibleContentWhenContentRevisionChanges() async throws { let session = makeSessionWithNoOpAttachment() let viewController = WebInspectorViewController(session: session) viewController.horizontalSizeClassOverrideForTesting = .regular @@ -584,7 +547,7 @@ struct ParentContainerTests { ) let initialRootViewController = try #require(regularHost.viewControllers.first) - session.installDataContext(makeContext()) + session.interface.contextDidChange() let didRebuildContent = await waitUntilRegularHostRendered(in: regularHost) { guard let currentRootViewController = regularHost.viewControllers.first else { @@ -598,7 +561,7 @@ struct ParentContainerTests { @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 } @@ -654,9 +617,9 @@ struct ParentContainerTests { @Test func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() async throws { let session = WebInspectorViewControllerPreviewFixtures.makeSession() - let model = try await NetworkPanelModel.make(context: 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) } @@ -968,7 +931,7 @@ struct ParentContainerTests { ) viewController.loadViewIfNeeded() #expect(contentStore.contentCountForTesting > 0) - let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision + let stableModel = session.model viewController.finishRootPresentationLifecycleForTesting() #expect(await waitUntilDetachCount(1, in: session)) @@ -976,7 +939,8 @@ struct ParentContainerTests { #expect(session.detachCountForTesting == 1) #expect(contentStore.contentCountForTesting == 0) - #expect(session.interface.contextBoundContentRevision > contentRevisionBeforeRetirement) + #expect(session.model === stableModel) + #expect(session.model.state == .detached) } @Test @@ -1118,7 +1082,7 @@ struct ParentContainerTests { viewController.automaticallyDetachesOnDismiss = false viewController.loadViewIfNeeded() #expect(contentStore.contentCountForTesting > 0) - let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision + let stableModel = session.model let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting viewController.finishRootPresentationLifecycleForTesting() @@ -1126,7 +1090,7 @@ struct ParentContainerTests { #expect(session.detachCountForTesting == 0) #expect(contentStore.contentCountForTesting == 0) - #expect(session.interface.contextBoundContentRevision == contentRevisionBeforeRetirement) + #expect(session.model === stableModel) } @Test @@ -1274,7 +1238,7 @@ struct ParentContainerTests { requestID: "1", url: "https://example.com/app.js" ) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) let compactResourceViewController = try #require( WebInspectorTab.ContentFactory.makeViewController( for: .network, @@ -1359,13 +1323,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 { @@ -1450,9 +1414,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( @@ -1463,9 +1427,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 ) @@ -1477,6 +1441,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" From a648519ba2082c27693f252de60773991d2cce0c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:42:50 +0900 Subject: [PATCH 68/81] test: cover auxiliary reply purposes --- .../WebInspectorProxyTransportCommandBackendTests.swift | 2 +- Tests/WebInspectorUITests/CustomTabResourceTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index ae69ab8d..6c3338de 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -3167,7 +3167,7 @@ private func requireCapabilityReplyPurpose( #expect(purposes.count == 1) let purpose = try #require(purposes[pendingKey]) switch purpose { - case .direct, .modelCommand: + case .direct, .modelCommand, .capabilityAuxiliary: Issue.record("Expected a capability reply purpose.") return 0 case .modelBootstrap: diff --git a/Tests/WebInspectorUITests/CustomTabResourceTests.swift b/Tests/WebInspectorUITests/CustomTabResourceTests.swift index c1c85407..04b2a969 100644 --- a/Tests/WebInspectorUITests/CustomTabResourceTests.swift +++ b/Tests/WebInspectorUITests/CustomTabResourceTests.swift @@ -118,7 +118,7 @@ struct CustomTabResourceTests { let finished = WebInspectorTestGate() let session = WebInspectorSession(tabs: []) var store: PresentationContentStore? = PresentationContentStore() - weak var weakStore = store + weak let weakStore = store let host = try #require(store).customViewController( for: key, session: session From cc974e4b1aee3538d37283635d19dcd706c05636 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:44:21 +0900 Subject: [PATCH 69/81] refactor(ui): remove obsolete context epochs --- .../Containers/CompactTabBarController.swift | 15 +--- .../RegularTabContentViewController.swift | 14 +-- .../Containers/WebInspectorSession.swift | 9 -- Sources/WebInspectorUI/DOMTabController.swift | 6 +- .../WebInspectorUI/NetworkTabController.swift | 16 +--- .../Tabs/PresentationContentStore.swift | 88 +++++-------------- 6 files changed, 33 insertions(+), 115 deletions(-) diff --git a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift index e28ae98d..90ab74aa 100644 --- a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift +++ b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift @@ -9,7 +9,6 @@ package final class CompactTabBarController: UITabBarController, UITabBarControl 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 @@ -83,26 +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 - contentStore.prepare(for: contentRevision) - 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) diff --git a/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift b/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift index cae17224..795b6e3e 100644 --- a/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift +++ b/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift @@ -9,7 +9,6 @@ package final class RegularTabContentViewController: UINavigationController { 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 = { @@ -93,17 +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 - contentStore.prepare(for: contentRevision) - 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]) { @@ -124,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) @@ -134,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 } diff --git a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift index 39f49efb..974d04af 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -250,7 +250,6 @@ extension WebInspectorSession { package final class InterfaceModel { package let tabs: [WebInspectorTab] package private(set) var selectedItemID: WebInspectorTab.DisplayItem.ID? - package private(set) var contextBoundContentRevision = 0 @ObservationIgnored private let projection = WebInspectorTab.DisplayProjection() package init(tabs: [WebInspectorTab] = [.dom, .network]) { @@ -305,14 +304,6 @@ package final class InterfaceModel { selectedItemID = displayItemID } - package func contextDidChange() { - precondition( - contextBoundContentRevision < Int.max, - "A presentation content revision must not overflow." - ) - contextBoundContentRevision += 1 - } - package var selectedTab: WebInspectorTab? { guard let selectedItemID else { return nil diff --git a/Sources/WebInspectorUI/DOMTabController.swift b/Sources/WebInspectorUI/DOMTabController.swift index 170c7688..12a14b3e 100644 --- a/Sources/WebInspectorUI/DOMTabController.swift +++ b/Sources/WebInspectorUI/DOMTabController.swift @@ -87,8 +87,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { contentStore: PresentationContentStore ) -> DOMTreeViewController { contentStore.viewController( - for: contentKey(ContentID.tree), - contextEpoch: session.interface.contextBoundContentRevision + for: contentKey(ContentID.tree) ) { DOMTreeViewController(context: session.model) } @@ -99,8 +98,7 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { contentStore: PresentationContentStore ) -> DOMElementViewController { contentStore.viewController( - for: contentKey(ContentID.element), - contextEpoch: session.interface.contextBoundContentRevision + for: contentKey(ContentID.element) ) { DOMElementViewController(context: session.model) } diff --git a/Sources/WebInspectorUI/NetworkTabController.swift b/Sources/WebInspectorUI/NetworkTabController.swift index a499dd88..b4f1b2d0 100644 --- a/Sources/WebInspectorUI/NetworkTabController.swift +++ b/Sources/WebInspectorUI/NetworkTabController.swift @@ -23,10 +23,8 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { contentStore: PresentationContentStore, layout: WebInspectorTab.HostLayout ) -> UIViewController { - let contextEpoch = session.interface.contextBoundContentRevision return contentStore.networkViewController( - context: session.model, - contextEpoch: contextEpoch + context: session.model ) { [weak contentStore] model in guard let contentStore else { preconditionFailure("A Network resource lost its presentation content store.") @@ -34,7 +32,6 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { return readyViewController( layout: layout, contentStore: contentStore, - contextEpoch: contextEpoch, model: model ) } @@ -43,17 +40,14 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { private func readyViewController( layout: WebInspectorTab.HostLayout, contentStore: PresentationContentStore, - contextEpoch: Int, model: NetworkPanelModel ) -> UIViewController { let listViewController = cachedListViewController( contentStore: contentStore, - contextEpoch: contextEpoch, model: model ) let detailViewController = cachedDetailViewController( contentStore: contentStore, - contextEpoch: contextEpoch, model: model ) @@ -77,12 +71,10 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { private func cachedListViewController( contentStore: PresentationContentStore, - contextEpoch: Int, model: NetworkPanelModel ) -> NetworkListViewController { contentStore.viewController( - for: contentKey(ContentID.list), - contextEpoch: contextEpoch + for: contentKey(ContentID.list) ) { NetworkListViewController(model: model) } @@ -90,12 +82,10 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { private func cachedDetailViewController( contentStore: PresentationContentStore, - contextEpoch: Int, model: NetworkPanelModel ) -> NetworkDetailViewController { contentStore.viewController( - for: contentKey(ContentID.detail), - contextEpoch: contextEpoch + for: contentKey(ContentID.detail) ) { NetworkDetailViewController( model: model, diff --git a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift index 5a3b6d58..63f5d992 100644 --- a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -24,9 +24,9 @@ package final class PresentationContentStore { private enum NetworkResourceState { case idle - case loading(contextEpoch: Int, generation: UInt64) - case ready(contextEpoch: Int, generation: UInt64, model: NetworkPanelModel) - case failed(contextEpoch: Int, generation: UInt64, message: String) + case loading(generation: UInt64) + case ready(generation: UInt64, model: NetworkPanelModel) + case failed(generation: UInt64, message: String) } private enum CustomResourceState { @@ -86,7 +86,6 @@ package final class PresentationContentStore { WebInspectorTab.ContentKey: UInt64 ] = [:] private var networkResourceState: NetworkResourceState = .idle - package private(set) var contextEpoch: Int? package private(set) var networkResourceGeneration: UInt64 = 0 package private(set) var networkResourceRevision: UInt64 = 0 @ObservationIgnored private var networkRetirementGeneration: UInt64 = 0 @@ -105,7 +104,7 @@ package final class PresentationContentStore { for task in customResourceTasks.values { task.cancel() } - if case let .ready(_, _, model) = networkResourceState { + if case let .ready(_, model) = networkResourceState { model.synchronouslyCancelForOwnerDeinit() } for resourceViewController in networkResourceViewControllers { @@ -127,30 +126,26 @@ package final class PresentationContentStore { .loading case .ready: .ready - case let .failed(_, _, message): + case let .failed(_, message): .failed(message) } } package func viewController( for key: WebInspectorTab.ContentKey, - contextEpoch: Int, make: () -> Content ) -> Content { - prepare(for: contextEpoch) return contentCache.viewController(for: key, make: make) } package func networkViewController( context: WebInspectorModelContext, - contextEpoch: Int, makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController ) -> NetworkTabResourceViewController { - prepare(for: contextEpoch) if let networkContext { precondition( networkContext === context, - "A presentation context must change together with its context epoch." + "One presentation content store cannot bind multiple model contexts." ) } else { networkContext = context @@ -165,14 +160,9 @@ package final class PresentationContentStore { switch networkResourceState { case .idle: - startNetworkResource(context: context, contextEpoch: contextEpoch) - case let .loading(resourceEpoch, _), - let .ready(resourceEpoch, _, _), - let .failed(resourceEpoch, _, _): - precondition( - resourceEpoch == contextEpoch, - "A Network resource must change together with its presentation context epoch." - ) + startNetworkResource(context: context) + case .loading, .ready, .failed: + break } renderNetworkResource(on: viewController) return viewController @@ -203,20 +193,6 @@ package final class PresentationContentStore { renderCustomResource(for: key, on: viewController) return viewController } - - /// Begins a context transition synchronously. Any next Network load waits - /// for the old load and model retirement before it can publish ready state. - package func prepare(for contextEpoch: Int) { - guard self.contextEpoch != contextEpoch else { - return - } - beginNetworkRetirement() - contentCache.removeAll() - networkResourceViewControllers.removeAll() - networkContext = nil - self.contextEpoch = contextEpoch - } - /// Retires every presentation resource and waits for asynchronous owners. package func clear() async { beginNetworkRetirement() @@ -237,7 +213,6 @@ package final class PresentationContentStore { contentCache.removeAll() networkResourceViewControllers.removeAll() networkContext = nil - contextEpoch = nil let retirementGeneration = networkRetirementGeneration let retirementTask = networkRetirementTask for task in customTasks { @@ -380,22 +355,14 @@ package final class PresentationContentStore { } private func startNetworkResource( - context: WebInspectorModelContext, - contextEpoch: Int + context: WebInspectorModelContext ) { - precondition( - self.contextEpoch == contextEpoch, - "A Network resource cannot start for an inactive presentation context epoch." - ) guard case .idle = networkResourceState else { return } let generation = advanceNetworkResourceGeneration() - networkResourceState = .loading( - contextEpoch: contextEpoch, - generation: generation - ) + networkResourceState = .loading(generation: generation) advanceNetworkResourceRevision() renderNetworkResource() @@ -403,10 +370,7 @@ package final class PresentationContentStore { let makeNetworkPanelModel = makeNetworkPanelModel networkResourceTask = Task { @MainActor [weak self] in await retirementTask?.value - guard self?.isCurrentNetworkResource( - contextEpoch: contextEpoch, - generation: generation - ) == true else { + guard self?.isCurrentNetworkResource(generation: generation) == true else { return } self?.networkRetirementTask = nil @@ -414,16 +378,12 @@ package final class PresentationContentStore { do { let model = try await makeNetworkPanelModel(context) guard let self, - isCurrentNetworkResource( - contextEpoch: contextEpoch, - generation: generation - ) else { + isCurrentNetworkResource(generation: generation) else { await model.retire() return } networkResourceTask = nil networkResourceState = .ready( - contextEpoch: contextEpoch, generation: generation, model: model ) @@ -431,16 +391,12 @@ package final class PresentationContentStore { renderNetworkResource() } catch { guard let self, - isCurrentNetworkResource( - contextEpoch: contextEpoch, - generation: generation - ) else { + isCurrentNetworkResource(generation: generation) else { return } let message = error.localizedDescription networkResourceTask = nil networkResourceState = .failed( - contextEpoch: contextEpoch, generation: generation, message: message ) @@ -455,7 +411,7 @@ package final class PresentationContentStore { networkResourceTask = nil loadTask?.cancel() let readyModel: NetworkPanelModel? - if case let .ready(_, _, model) = networkResourceState { + if case let .ready(_, model) = networkResourceState { readyModel = model } else { readyModel = nil @@ -490,16 +446,14 @@ package final class PresentationContentStore { } private func isCurrentNetworkResource( - contextEpoch: Int, generation: UInt64 ) -> Bool { - guard self.contextEpoch == contextEpoch, - networkResourceGeneration == generation else { + guard networkResourceGeneration == generation else { return false } switch networkResourceState { - case let .loading(resourceEpoch, resourceGeneration): - return resourceEpoch == contextEpoch && resourceGeneration == generation + case let .loading(resourceGeneration): + return resourceGeneration == generation case .idle, .ready, .failed: return false } @@ -537,9 +491,9 @@ package final class PresentationContentStore { switch networkResourceState { case .idle, .loading: viewController.showLoading(revision: networkResourceRevision) - case let .ready(_, _, model): + case let .ready(_, model): viewController.showReady(model, revision: networkResourceRevision) - case let .failed(_, _, message): + case let .failed(_, message): viewController.showFailure(message, revision: networkResourceRevision) } } @@ -554,7 +508,7 @@ package final class PresentationContentStore { } package var networkPanelModelForTesting: NetworkPanelModel? { - guard case let .ready(_, _, model) = networkResourceState else { + guard case let .ready(_, model) = networkResourceState else { return nil } return model From ce09be19cd9e065f81b688900e330f7497249243 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:47:01 +0900 Subject: [PATCH 70/81] test(ui): assert stable presentation ownership --- .../ParentContainerTests.swift | 110 +++++++++--------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index 9f96f000..054683f6 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -249,16 +249,18 @@ struct ParentContainerTests { } @Test - func presentationContentStoreEvictsEntriesFromPreviousContextEpoch() { + func presentationContentStoreReusesEntriesUntilRootClear() async { let contentStore = PresentationContentStore() - let key = WebInspectorTab.ContentKey(tabID: "epoch-tab", contentID: "root") + let key = WebInspectorTab.ContentKey(tabID: "cached-tab", contentID: "root") - let first = contentStore.viewController(for: key, contextEpoch: 0) { UIViewController() } - #expect(contentStore.viewController(for: key, contextEpoch: 0) { UIViewController() } === first) + let first = contentStore.viewController(for: key) { UIViewController() } + #expect(contentStore.viewController(for: key) { UIViewController() } === first) - let second = contentStore.viewController(for: key, contextEpoch: 1) { UIViewController() } + await contentStore.clear() + + let second = contentStore.viewController(for: key) { UIViewController() } #expect(second !== first) - #expect(contentStore.viewController(for: key, contextEpoch: 1) { UIViewController() } === second) + #expect(contentStore.viewController(for: key) { UIViewController() } === second) #expect(contentStore.contentCountForTesting == 1) } @@ -284,8 +286,7 @@ struct ParentContainerTests { } let resourceViewController = contentStore.networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in UIViewController() } @@ -312,8 +313,7 @@ struct ParentContainerTests { let context = makeContext() let contentStore = PresentationContentStore() let firstResourceViewController = contentStore.networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in UIViewController() } @@ -321,8 +321,7 @@ struct ParentContainerTests { let model = try #require(contentStore.networkPanelModelForTesting) let secondResourceViewController = contentStore.networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in UIViewController() } @@ -344,22 +343,24 @@ struct ParentContainerTests { } @Test - func contextReplacementRejectsLateNetworkModelBeforePublishingNewReadyState() async throws { - let firstContext = makeContext() - let secondContext = makeContext() + func rootClearRejectsLateNetworkModelBeforePublishingRestartedReadyState() async throws { + let context = makeContext() let firstFactoryStarted = WebInspectorTestGate() let firstFactoryRelease = WebInspectorTestGate() let secondFactoryStarted = WebInspectorTestGate() let secondFactoryRelease = WebInspectorTestGate() var firstModel: NetworkPanelModel? - let contentStore = PresentationContentStore { context in - let model = try await NetworkPanelModel.make(context: context) - if context === firstContext { + 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(context === secondContext) + #expect(factoryInvocationCount == 2) secondFactoryStarted.open() await secondFactoryRelease.waiter.wait() } @@ -367,37 +368,44 @@ struct ParentContainerTests { } let firstResourceViewController = contentStore.networkViewController( - context: firstContext, - contextEpoch: 1 + context: context ) { _ in UIViewController() } await firstFactoryStarted.waiter.wait() let withheldFirstModel = try #require(firstModel) - let secondResourceViewController = contentStore.networkViewController( - context: secondContext, - contextEpoch: 2 - ) { _ in - UIViewController() + let clearTask = Task { @MainActor in + await contentStore.clear() + } + for _ in 0..<100 where contentStore.networkResourceStatus != .idle { + await Task.yield() } #expect(firstResourceViewController.phase == .loading) - #expect(secondResourceViewController.phase == .loading) - #expect(contentStore.contextEpoch == 2) + #expect(contentStore.networkResourceStatus == .idle) firstFactoryRelease.open() - await secondFactoryStarted.waiter.wait() + await clearTask.value #expect(withheldFirstModel.isRetiredForTesting) - #expect(contentStore.networkResourceStatus == .loading) + #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 === secondContext) + #expect(secondModel.context === context) #expect(secondResourceViewController.phase == .ready) #expect(firstResourceViewController.phase == .loading) @@ -411,8 +419,7 @@ struct ParentContainerTests { throw NetworkResourceFailure() } let resourceViewController = contentStore.networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in Issue.record("A failed Network resource must not create ready content.") return UIViewController() @@ -448,8 +455,7 @@ struct ParentContainerTests { } weak let retainedStore = contentStore let resourceViewController = try #require(contentStore).networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in UIViewController() } @@ -470,8 +476,7 @@ struct ParentContainerTests { var contentStore: PresentationContentStore? = PresentationContentStore() weak let retainedStore = contentStore let resourceViewController = try #require(contentStore).networkViewController( - context: context, - contextEpoch: 1 + context: context ) { _ in UIViewController() } @@ -516,29 +521,26 @@ struct ParentContainerTests { } @Test - func compactHostRebuildsActiveTabsWhenContentRevisionChanges() 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.interface.contextDidChange() + 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 regularHostRebuildsVisibleContentWhenContentRevisionChanges() async throws { - let session = makeSessionWithNoOpAttachment() + func regularHostPreservesVisibleContentAcrossStableModelAttach() async throws { + let session = makeAttachmentSession() let viewController = WebInspectorViewController(session: session) viewController.horizontalSizeClassOverrideForTesting = .regular viewController.loadViewIfNeeded() @@ -546,16 +548,12 @@ struct ParentContainerTests { viewController.activeHostViewControllerForTesting as? RegularTabContentViewController ) let initialRootViewController = try #require(regularHost.viewControllers.first) + let stableModel = session.model - session.interface.contextDidChange() + 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 From ba369823bde21918a02d90b4a5b5bd38fb5e6954 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:59:00 +0900 Subject: [PATCH 71/81] fix(proxy): linearize picker activation replies --- .../Transport/TransportReplyStore.swift | 28 ++++ .../Transport/TransportSession.swift | 133 +++++++++++++++--- .../ConnectionModelFeedTests.swift | 105 +++++++++++++- ...torProxyTransportCommandBackendTests.swift | 2 +- 4 files changed, 240 insertions(+), 28 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index 94a92a27..3ce93d3a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -7,6 +7,12 @@ extension TransportSession { bindingGeneration: WebInspectorPage.Generation?, documentEpoch: ModelDocumentEpoch? ) + case elementPickerMode( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + documentEpoch: ModelDocumentEpoch, + enabled: Bool + ) case modelCommand( authorization: ConnectionModelCommandAuthorization, operationID: UInt64 @@ -92,6 +98,28 @@ extension TransportSession { ) } + 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, diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 12923b94..7b0b4e25 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -43,6 +43,16 @@ private struct ConnectionDirectCommandAdmission: Sendable { 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 @@ -1165,7 +1175,11 @@ package actor ConnectionCore { if let result = transportLocalResult(for: command, targetID: targetID) { return result } - return try await sendTarget(command, targetID: targetID, admission: admission) + return try await sendTarget( + command, + targetID: targetID, + owner: .direct(admission) + ) } private func beginModelCommand( @@ -2432,16 +2446,27 @@ package actor ConnectionCore { failureOverride: .staleIdentifier, pendingFailureReason: .staleIdentifier ) - let directReplies = replyStore.removePendingReplies { pending in + let bindingReplies = replyStore.removePendingReplies { pending in guard pending.targetID == targetID, - isDocumentSensitive(pending.domain), - case let .direct(bindingGeneration, documentEpoch) = pending.purpose else { + isDocumentSensitive(pending.domain) 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: directReplies.map { + effects.pendingFailures.append(contentsOf: bindingReplies.map { ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) }) @@ -2855,9 +2880,11 @@ package actor ConnectionCore { return try await sendTarget( command, targetID: targetID, - admission: ConnectionDirectCommandAdmission( - bindingGeneration: generation, - documentEpoch: modelDocumentEpoch(for: targetID) + owner: .elementPickerMode( + key: key, + generation: generation, + documentEpoch: modelDocumentEpoch(for: targetID), + enabled: enabled ) ) } @@ -3634,7 +3661,7 @@ package actor ConnectionCore { private func sendTarget( _ command: ProtocolCommand, targetID: ProtocolTarget.ID, - admission: ConnectionDirectCommandAdmission + owner: ConnectionTargetCommandOwner ) async throws -> ProtocolCommand.Result { let innerCommandID = allocateCommandID() let outerCommandID = allocateCommandID() @@ -3651,13 +3678,25 @@ package actor ConnectionCore { targetIdentifier: targetID.rawValue, message: message ) - let pending = makeDirectPendingReply( - domain: command.domain, - method: command.method, - targetID: targetID, - promise: promise, - admission: admission - ) + let pending: TransportSession.PendingReply = switch owner { + case let .direct(admission): + makeDirectPendingReply( + domain: command.domain, + method: command.method, + targetID: targetID, + promise: promise, + admission: admission + ) + 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) @@ -3991,6 +4030,25 @@ package actor ConnectionCore { 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.") @@ -4043,6 +4101,13 @@ package actor ConnectionCore { 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, @@ -4069,6 +4134,30 @@ package actor ConnectionCore { } } + /// 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, @@ -4493,13 +4582,17 @@ package actor ConnectionCore { failureOverride: .staleIdentifier, pendingFailureReason: .staleIdentifier ) - let directReplies = replyStore.removePendingReplies { pending in - guard case let .direct(bindingGeneration, _) = pending.purpose else { + 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 } - return bindingGeneration == oldGeneration } - commandInvalidation.pendingFailures.append(contentsOf: directReplies.map { + commandInvalidation.pendingFailures.append(contentsOf: bindingReplies.map { ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) }) diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 4f83a0a6..2c1502e6 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -195,7 +195,12 @@ func directElementPickerDiscardsInspectReceivedBeforeModeActivationReply() async @Test func directElementPickerReinitializesAndReactivatesOnReplacementPage() async throws { let backend = FakeTransportBackend() - let core = ConnectionCore(backend: backend, responseTimeout: nil) + 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", @@ -246,16 +251,42 @@ func directElementPickerReinitializesAndReactivatesOnReplacementPage() async thr 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 modelFeedRespond(to: activate, core: core) - - _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( - targetID: "page-new", - message: modelFeedInspectorInspectMessage(objectID: "after-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 @@ -3934,6 +3965,66 @@ private func modelFeedTargetDispatchMessage( 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 diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 6c3338de..0c90e42d 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -3167,7 +3167,7 @@ private func requireCapabilityReplyPurpose( #expect(purposes.count == 1) let purpose = try #require(purposes[pendingKey]) switch purpose { - case .direct, .modelCommand, .capabilityAuxiliary: + case .direct, .elementPickerMode, .modelCommand, .capabilityAuxiliary: Issue.record("Expected a capability reply purpose.") return 0 case .modelBootstrap: From 8ff14d4eba444a476742ac9aa94d7ad64bee956d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:11:06 +0900 Subject: [PATCH 72/81] test(ui): migrate asynchronous resource contracts --- .../DOMContainerTests.swift | 6 +- .../NetworkDetailViewControllerTests.swift | 128 +++++++------ .../ParentContainerTests.swift | 181 ++++++++++++------ 3 files changed, 202 insertions(+), 113 deletions(-) diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index d0c7a410..fda6e1e9 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -376,8 +376,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,8 +404,7 @@ 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;")) try context.selectDOMNode(nil) diff --git a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift index 8157e237..f5a6c84f 100644 --- a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift +++ b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift @@ -564,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 = try await NetworkPanelModel.make(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: context) - model.selectRequest(request) - let viewController = makeNetworkDetailViewController(model: model) - let window = showInWindow(viewController) - defer { window.isHidden = true } - viewController.setModeForTesting(.headers) + 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) + 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) + viewController.beginAppearanceTransition(false, animated: false) + viewController.endAppearanceTransition() + viewController.setModeForTesting(.preview) - #expect(request.responseBody.phase == .available) - #expect(viewController.headersTextViewForTesting.renderedTextForTesting.contains("content-type: application/json")) + #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." + ) - 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 diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index 054683f6..5db595b0 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -225,27 +225,36 @@ struct ParentContainerTests { let viewController = WebInspectorViewController(session: session) let contentStore = viewController.presentationContentStoreForTesting let stableModel = session.model - let initialContent = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(tab.id), - session: session, - contentStore: contentStore, - hostLayout: .compact + 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 + ) + await contentStore.waitForCustomResourceTaskForTesting(for: key) + let initialContent = try #require( + contentStore.customReadyViewControllerForTesting(for: key) ) - let initialContentCount = contentStore.contentCountForTesting try await attach(session) - let contentAfterAttach = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(tab.id), - session: session, - contentStore: contentStore, - hostLayout: .compact + let hostAfterAttach = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(tab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) #expect(session.model === stableModel) #expect(session.model.state == .attached) - #expect(contentAfterAttach === initialContent) - #expect(contentStore.contentCountForTesting == initialContentCount) + #expect(hostAfterAttach !== initialHost) + #expect(hostAfterAttach.readyViewControllerForTesting === initialContent) + #expect(contentStore.customReadyViewControllerForTesting(for: key) === initialContent) } @Test @@ -637,7 +646,7 @@ struct ParentContainerTests { } @Test - func customTabUsesPublicDescriptorAndCachedViewControllerFactory() throws { + func customTabUsesPublicDescriptorAndSharedAsyncResourceFactory() async throws { let customViewController = UIViewController() var factoryCallCount = 0 var factorySession: WebInspectorSession? @@ -653,6 +662,7 @@ struct ParentContainerTests { 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) @@ -678,11 +688,13 @@ struct ParentContainerTests { )?.title == "Console" ) - let compactContent = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(customTab.id), - session: session, - contentStore: contentStore, - hostLayout: .compact + 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), @@ -691,32 +703,40 @@ struct ParentContainerTests { 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, - contentStore: contentStore, - hostLayout: .compact + 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 rootContentStoreDoesNotCloseCycleThroughSessionRetainingCustomController() 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? - autoreleasepool { + do { let customTab = WebInspectorTab( id: "webinspector_custom_retaining_session", title: "Retaining Session", @@ -727,21 +747,28 @@ struct ParentContainerTests { let session = WebInspectorSession(tabs: [customTab]) let root = WebInspectorViewController(session: session) let contentStore = root.presentationContentStoreForTesting - let content = WebInspectorTab.ContentFactory.makeViewController( + let key = customContentKey(for: customTab.id) + let host = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, contentStore: contentStore, hostLayout: .compact ) - let retainingContent = try? #require(content as? SessionRetainingViewController) + await contentStore.waitForCustomResourceTaskForTesting(for: key) + let retainingContent = try #require( + contentStore.customReadyViewControllerForTesting(for: key) + as? SessionRetainingViewController + ) - #expect(retainingContent?.session === session) + #expect(retainingContent.session === session) retainedRoot = root retainedStore = contentStore retainedCache = contentStore.contentCacheForTesting retainedSession = session - retainedContent = content + retainedContent = retainingContent + _ = host } + autoreleasepool {} #expect(retainedRoot == nil) #expect(retainedStore == nil) @@ -751,7 +778,7 @@ struct ParentContainerTests { } @Test - func presentationContentStoreDeinitDetachesExternallyRetainedCustomController() throws { + func presentationContentStoreDeinitDetachesExternallyRetainedCustomController() async throws { let customViewController = UIViewController() let customTab = WebInspectorTab( id: "webinspector_custom_store_deinit", @@ -763,6 +790,7 @@ struct ParentContainerTests { 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, @@ -770,7 +798,12 @@ struct ParentContainerTests { hostLayout: .regular ) regularContent.loadViewIfNeeded() - #expect(customViewController.parent === regularContent) + await contentStore?.waitForCustomResourceTaskForTesting(for: key) + let resourceHost = try #require( + regularContent.children.first as? CustomTabResourceViewController + ) + #expect(resourceHost.readyViewControllerForTesting === customViewController) + #expect(customViewController.parent === resourceHost) contentStore = nil @@ -779,7 +812,7 @@ struct ParentContainerTests { } @Test - func customTabDisplayItemDoesNotCollideWithInternalDOMElementIdentifier() throws { + func customTabDisplayItemDoesNotCollideWithInternalDOMElementIdentifier() async throws { let customViewController = UIViewController() let customTab = WebInspectorTab( id: WebInspectorTab.DisplayItem.domElementID, @@ -814,17 +847,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, - contentStore: contentStore, - hostLayout: .compact + let customHost = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController + ) + await contentStore.waitForCustomResourceTaskForTesting( + for: customContentKey(for: customTab.id) ) - #expect(customContent === customViewController) + #expect(customHost.readyViewControllerForTesting === customViewController) } @Test - func regularCustomTabWrapsNavigationControllerContent() throws { + func regularCustomTabWrapsNavigationControllerContent() async throws { let customRootViewController = UIViewController() let customNavigationController = UINavigationController(rootViewController: customRootViewController) let customTab = WebInspectorTab( @@ -835,18 +873,26 @@ struct ParentContainerTests { customNavigationController } let session = WebInspectorSession(tabs: [customTab]) + let contentStore = PresentationContentStore() let host = RegularTabContentViewController( session: session, - contentStore: PresentationContentStore() + 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 @@ -931,9 +977,14 @@ struct ParentContainerTests { #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(contentStore.contentCountForTesting == 0) @@ -947,30 +998,38 @@ struct ParentContainerTests { 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, contentStore: contentStore, hostLayout: .compact ) + await contentStore.waitForCustomResourceTaskForTesting(for: key) let navigationController = UINavigationController(rootViewController: viewController) let window = showInWindow(navigationController) defer { window.isHidden = true } #expect(navigationController.view.window === window) - #expect(contentStore.contentCountForTesting > 0) + #expect(contentStore.customResourceStatusForTesting(for: key) == .ready) let coveringViewController = UIViewController() navigationController.pushViewController(coveringViewController, animated: false) #expect(navigationController.topViewController === coveringViewController) #expect(session.detachCountForTesting == 0) - #expect(contentStore.contentCountForTesting > 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(contentStore.contentCountForTesting == 0) + #expect( + await viewController.waitForRootPresentationRetirementTaskCompletionForTesting( + after: retirementBaseline + ) + ) + #expect(session.detachCountForTesting == 1) + #expect(contentStore.customResourceStatusForTesting(for: key) == nil) } @Test @@ -979,24 +1038,32 @@ struct ParentContainerTests { 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, contentStore: contentStore, hostLayout: .compact ) + await contentStore.waitForCustomResourceTaskForTesting(for: key) let window = showInWindow(viewController) defer { window.isHidden = true } #expect(viewController.view.window === window) - #expect(contentStore.contentCountForTesting > 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(contentStore.contentCountForTesting == 0) + #expect( + await viewController.waitForRootPresentationRetirementTaskCompletionForTesting( + after: retirementBaseline + ) + ) + #expect(session.detachCountForTesting == 1) + #expect(contentStore.customResourceStatusForTesting(for: key) == nil) } @Test @@ -1459,6 +1526,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 From dfa94dc12f02b05fe1d121ff5a675165cd2c21fe Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:17:08 +0900 Subject: [PATCH 73/81] fix(proxy): let close retire rolling back feeds --- .../Transport/TransportSession.swift | 10 ++++- .../ConnectionModelFeedTests.swift | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 7b0b4e25..66a09996 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -966,7 +966,15 @@ package actor ConnectionCore { } guard let currentRegistration = modelFeed, currentRegistration.id == id else { - preconditionFailure("A model feed lost its registration before rollback completed.") + guard !isOpen else { + preconditionFailure( + "A model feed lost its registration before rollback completed." + ) + } + // 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 diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 2c1502e6..0af9b00a 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -2698,6 +2698,44 @@ func terminalDuringModelFeedRollbackCompletesWithoutLeakingClaim() async throws #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], + capacity: 16 + ) + } + 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() From e84b5b66f22de68d787400b841de267ddd5a872d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:20:58 +0900 Subject: [PATCH 74/81] docs: publish redesigned DataKit surface --- .github/workflows/docc.yml | 3 +- Docs/WebInspectorKitsArchitecture.md | 4 +- .../WebInspectorDataKit.md | 49 ++++++++++--------- 3 files changed, 31 insertions(+), 25 deletions(-) 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/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 9c5e02c3..f267a908 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1978,8 +1978,8 @@ The migration removes rather than deprecates the following surfaces: - Duplicate direct Context/model convenience operations after their `WebInspectorModelContext` facade operation or scoped-resource equivalent exists. -- `WebInspectorFetchableModel`, `WebInspectorPersistentModel`, generic - `WebInspectorFetchDescriptor`, arbitrary key-path sort/section descriptors, +- `WebInspectorFetchableModel`, generic `WebInspectorFetchDescriptor`, + arbitrary key-path sort/section descriptors, `WebInspectorFetchRequest`, `WebInspectorMutationOptions`, `WebInspectorStaleModelPolicy`, and `WebInspectorFetchedResultsController`. diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md index e07e2ed1..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 = try await context.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 ``WebInspectorContext`` directly 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.setElementPickerEnabled(true) -try await context.reloadPage() +try await context.reload() -let result = try await context.evaluate("document.title") +let result = try await context.withRuntimeObjectGroup { group in + try await group.evaluate("document.title") +} print(result.object.description ?? "") ``` @@ -93,8 +94,11 @@ for await update in requests.updates() { ### Creating a Model Context -- ``WebInspectorContainer`` -- ``WebInspectorContext`` +- ``WebInspectorModelContext`` +- ``WebInspectorModelContext/Configuration`` +- ``WebInspectorModelContext/Domain`` +- ``WebInspectorModelContext/State`` +- ``WebInspectorModelContext/Failure`` ### Reading DOM State @@ -113,8 +117,9 @@ for await update in requests.updates() { - ``ConsoleQuery`` - ``ConsoleSort`` - ``ConsoleSection`` -- ``WebInspectorFetchRequest`` - ``WebInspectorFetchedResults`` +- ``WebInspectorFetchedResultsSnapshot`` +- ``WebInspectorFetchedResultsTransaction`` - ``WebInspectorFetchedResultsUpdate`` ### Runtime and CSS Models @@ -128,8 +133,8 @@ for await update in requests.updates() { ### Mutations -- ``WebInspectorMutationOptions`` - ``WebInspectorUndoPolicy`` -- ``WebInspectorStaleModelPolicy`` - ``DOMRevealPolicy`` -- ``DOMMutationResult`` +- ``DOMMutationOutcome`` +- ``DOMMutationFailure`` +- ``DOMUndoCapability`` From 1c5ef7741f5c30b067b631890d88e7b55406aaed Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:23:36 +0900 Subject: [PATCH 75/81] docs(architecture): record completed redesign --- Docs/WebInspectorKitsArchitecture.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index f267a908..3aaceac0 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1,6 +1,6 @@ # WebInspectorProxyKit and WebInspectorDataKit Architecture -- Status: proposed design gate +- 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 @@ -2271,10 +2271,10 @@ 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 +## Design Gate Approval -Implementation begins only after explicit approval of this document. Approval -accepts the breaking removals, the SwiftData-like caller-confined DataKit +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 From c85b508b89375bb5aa93ddcf7c55378d950c7ef7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:13:15 +0900 Subject: [PATCH 76/81] fix(proxy): keep page agent alive for CSS --- .../ContractTestSupport.swift | 4 +- .../WebInspectorDataKitTestRuntime.swift | 4 +- .../ConnectionEventScopeRegistry.swift | 59 +++++++++- .../Transport/TransportSession.swift | 76 ++++++++++--- .../WebInspectorDataKitTests.swift | 2 + .../ConnectionModelFeedTests.swift | 19 +++- ...torProxyTransportCommandBackendTests.swift | 104 ++++++++++++++++++ .../DOMContainerTests.swift | 1 + 8 files changed, 242 insertions(+), 27 deletions(-) diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index 43814247..3c65815b 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -169,7 +169,7 @@ actor ContractDataKitActor { try await context.attach(to: runtime.proxy, isolation: self) } var observedMethods: Set = [] - for _ in 0..<5 { + for _ in 0..<6 { let command = try await runtime.peer.commands.next() commands.append(command) try #require(command.destination == .target("page-main")) @@ -181,6 +181,7 @@ actor ContractDataKitActor { } } #expect(observedMethods == [ + "Page.enable", "CSS.enable", "Network.enable", "Console.enable", @@ -408,6 +409,7 @@ actor ContractDataKitActor { "Console.disable", "Network.disable", "CSS.disable", + "Page.disable", ] { _ = try await replyNext(expectedMethod: expectedMethod) } diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift index 2e8c32b5..b8a44a93 100644 --- a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift @@ -494,8 +494,8 @@ private actor ScenarioDriver { } switch command.method { - case "CSS.enable", "Console.enable", "Runtime.enable", - "CSS.disable", "Console.disable", "Runtime.disable", "Network.disable", + 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": diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift index ba05e5d4..2664b64e 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift @@ -6,6 +6,45 @@ struct ConnectionCapabilityKey: Hashable, Sendable { 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) @@ -15,7 +54,7 @@ enum ConnectionCapabilityLeaseOwner: Hashable, Sendable { struct ConnectionEventScopeRegistry { struct Entry: Sendable { var sink: WebInspectorEventSink? - var capability: ConnectionCapabilityKey + var capabilities: [ConnectionCapabilityKey] var capacity: Int? } @@ -27,15 +66,29 @@ struct ConnectionEventScopeRegistry { mutating func insert( _ sink: WebInspectorEventSink, - capability: ConnectionCapabilityKey, capacity: Int?, generation: WebInspectorPage.Generation ) { precondition(entries[sink.id] == nil, "Duplicate Web Inspector event scope identifier.") - entries[sink.id] = Entry(sink: sink, capability: capability, capacity: capacity) + 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) } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 66a09996..b09b84ed 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -640,7 +640,11 @@ package actor ConnectionCore { for domain in ModelDomain.ordered(configuredDomains) { do { let leaseOwner = ConnectionCapabilityLeaseOwner.modelFeed(id, domain) - for capabilityDomain in domain.capabilityDependencies { + let capabilityDomains = ConnectionCapabilityActivationPlan.domains( + for: domain.capabilityDependencies, + includePageDependencyForCSS: true + ) + for capabilityDomain in capabilityDomains { try Task.checkCancellation() guard isOpen else { throw terminalScopeError @@ -1053,7 +1057,6 @@ package actor ConnectionCore { let stream = mailbox.makeStream() let scopeID = WebInspectorProxyEventScopeID() - let key = ConnectionCapabilityKey(route: route, targetID: targetID, domain: domain) let generation = generation(for: route) let sink = WebInspectorEventSink( id: scopeID, @@ -1068,26 +1071,42 @@ package actor ConnectionCore { // logical lease can send its first wire enable command. eventScopes.insert( sink, - capability: key, capacity: capacity, generation: generation ) resumeEventScopeRegistrationWaitersIfNeeded() let leaseOwner = ConnectionCapabilityLeaseOwner.eventScope(scopeID) - let activation = beginCapabilityLease( - leaseOwner, - for: key, - generation: generation - ) do { - try await activateCapabilityLease( - leaseOwner, - for: key, - activation: activation + 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 { @@ -1113,15 +1132,40 @@ package actor ConnectionCore { resumeEventScopeRegistrationWaitersIfNeeded() entry.sink?.finish(nil) - let key = entry.capability let owner = ConnectionCapabilityLeaseOwner.eventScope(id) - if key.domain == .inspector { - if let error = await releaseElementPickerResources(owner, for: key) { + if let inspectorKey = entry.capabilities.last, + inspectorKey.domain == .inspector { + if let error = await releaseElementPickerResources(owner, for: inspectorKey) { throw error } return } - try await releaseCapabilityLease(owner, for: key) + 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 { diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index b3b893c4..62b3ff12 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -566,6 +566,7 @@ private func enqueueStartupReplies( document: DOM.Node ) async { if configuration.domains.contains(.css) { + await wire.respond(to: "Page.enable") await wire.respond(to: "CSS.enable") } if configuration.domains.contains(.network) { @@ -600,6 +601,7 @@ private func enqueueShutdownReplies( } if configuration.domains.contains(.css) { await wire.respond(to: "CSS.disable") + await wire.respond(to: "Page.disable") } } diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift index 0af9b00a..2106db18 100644 --- a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -434,7 +434,7 @@ func cssModelFeedUsesNormalizedDOMBootstrapAndOneSynchronization() async throws core: core, backend: backend, targetID: "page-main", - enableMethods: ["CSS.enable"] + enableMethods: ["Page.enable", "CSS.enable"] ) #expect(try await iterator.next() == nil) await core.close() @@ -1180,6 +1180,7 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as ) #expect(enableMethods == [ + "Page.enable", "CSS.enable", "Network.enable", "Console.enable", @@ -1259,6 +1260,7 @@ func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() as "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. @@ -1432,7 +1434,7 @@ func cancelledActivationDoesNotPublishReplayMarkerAfterOwnerStopsBeingDesired() await core.close() } -@Test(arguments: [0, 1, 2, 3]) +@Test(arguments: [0, 1, 2, 3, 4]) func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( failureIndex: Int ) async throws { @@ -1446,6 +1448,7 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( let configuredDomains = Set(ModelDomain.acquisitionOrder) let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) let enableModelDomains: [ModelDomain] = [ + .css, .css, .network, .console, @@ -1871,6 +1874,8 @@ func domDocumentInvalidationPrecedesMainTargetDeltasAndFreshBootstrap() async th 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 @@ -1958,7 +1963,7 @@ func domDocumentInvalidationPrecedesMainTargetDeltasAndFreshBootstrap() async th core: core, backend: backend, targetID: "page-main", - enableMethods: ["CSS.enable"] + enableMethods: ["Page.enable", "CSS.enable"] ) #expect(try await iterator.next() == nil) await core.close() @@ -3063,7 +3068,7 @@ func documentRefreshInvalidatesDocumentCommandsButPreservesBindingCommands() asy core: core, backend: backend, targetID: "page-main", - enableMethods: ["CSS.enable", "Network.enable"] + enableMethods: ["Page.enable", "CSS.enable", "Network.enable"] ) await core.close() } @@ -3514,7 +3519,11 @@ private func modelFeedExpectedEnableMethods( var seenDomains: Set = [] var methods: [String] = [] for domain in ModelDomain.ordered(configuredDomains) { - for dependency in domain.capabilityDependencies where dependency != .dom { + let capabilityDomains = ConnectionCapabilityActivationPlan.domains( + for: domain.capabilityDependencies, + includePageDependencyForCSS: true + ) + for dependency in capabilityDomains where dependency != .dom { guard seenDomains.insert(dependency).inserted else { continue } diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index 0c90e42d..c91232e2 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -1337,6 +1337,110 @@ func pendingRepliesClassifyDirectAndCapabilityOwners() async throws { #expect(await transport.pendingReplyPurposes().isEmpty) } +@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() + + let scopeTask = Task { + try await proxy.page.css.withEvents { _ in + await bodyGate.waitUntilReleased() + } + } + + 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 structuredFrameCSSScopeDoesNotRequirePageCapability() 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,"domains":["CSS"]}}}"# + ) + 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 bodyGate = CloseConnectionGate() + + let scopeTask = Task { + try await frame.css.withEvents { _ in + await bodyGate.waitUntilReleased() + } + } + + 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) + } + #expect(enableMethods == ["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: "{}" + ) + try await scopeTask.value + let completedMethods = try await backend.sentTargetMessages().map { + try messageMethod($0.message) + } + #expect(completedMethods == ["CSS.enable", "CSS.disable"]) +} + @Test func structuredNetworkScopeBuffersReplayBeforeEnableReplyAndBalancesDisable() async throws { let backend = FakeTransportBackend() diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index fda6e1e9..c703b2cc 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -1408,6 +1408,7 @@ struct DOMContainerTests { on wire: WebInspectorRawWireDriver, document: DOM.Node ) async throws { + await wire.respond(to: "Page.enable") await wire.respond(to: "CSS.enable") await wire.respond(to: "Network.enable") await wire.respond(to: "Console.enable") From 7890478b379b2821a5ca9c5b25075c6958205676 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:05:21 +0900 Subject: [PATCH 77/81] fix(data)!: preserve lossless model feed replay Start the DataKit consumer before capability activation and keep the authoritative model feed lossless so snapshot-to-delta continuity cannot fail under valid event bursts. BREAKING CHANGE: WebInspectorModelContext.Failure no longer exposes feedBufferOverflow because the authoritative model feed no longer drops records. --- Docs/WebInspectorKitsArchitecture.md | 72 ++-- .../WebInspectorModelContext.swift | 80 ++-- .../Transport/ConnectionModelFeed.swift | 15 +- .../Transport/TransportSession.swift | 51 ++- .../WebInspectorProxy.swift | 4 +- .../WebInspectorDataKitTests.swift | 55 +++ .../ConnectionModelFeedTests.swift | 377 +++++------------- 7 files changed, 280 insertions(+), 374 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 3aaceac0..76756ed7 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -643,11 +643,11 @@ package enum ModelBootstrapSnapshot: Sendable { /* target + epoch + typed snapsh package enum ModelDomain: Hashable, Sendable { /* configured domains */ } package enum ConnectionModelFeedError: Error, Sendable { case bootstrapFailed(domain: ModelDomain, message: String) - // exclusive-use, overflow, and consumer-lifecycle cases omitted + // exclusive-use and consumer-lifecycle cases omitted } ``` -The current transport slice implements the bounded exclusive feed, initial +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 @@ -659,9 +659,14 @@ 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, and `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 +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 @@ -699,13 +704,18 @@ 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 a bounded, oldest-first, single-consumer sequence. Overflow clears -pending records and immediately poisons both the feed and connection; there is -no silent drop, task relay, or unbounded default. 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. +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 @@ -950,30 +960,14 @@ public enum WebInspectorProxyError: Error, Sendable { - 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 also bounded. Overflow currently terminates the feed - and connection. The later DataKit driver maps that terminal failure to - `WebInspectorModelContext.Failure.feedBufferOverflow(capacity:)`; a mixed-feed - drop cannot truthfully attribute the failure to one domain. Neither layer may - fabricate a full domain resynchronization. +- 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 remains explicit opt-in, never the default. - -The context reducer owns the overflow transition in every lifecycle phase, and -every overflow puts the context in -`.failed(.feedBufferOverflow(capacity:))`. During attach/synchronization, shared -transition waiters also throw that `Failure`. After attach has returned, state -observation reports the failure; attachment is not retroactively failed. Before -publishing `.failed`, the same reducer invalidates all model command authority, -makes connection-scoped identities/resources stale or terminal, and resets -every store. Existing DOM tree and fetched-results owners publish a full empty -reset through their current identity and remain registered for recovery. New -queries and all subsequent domain operations reject with the recorded `Failure` -while the context is failed; state observation and explicit attach/detach/close -remain available. An explicit attach from `.failed` tears down the poisoned -connection, starts a new attachment generation, and delivers the new -reset/snapshots through those existing result owners. +- 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 @@ -1050,7 +1044,6 @@ public final class WebInspectorModelContext { public enum Failure: Error, Equatable, Sendable { case connection(ConnectionFailure) case bootstrap(domain: Domain, message: String) - case feedBufferOverflow(capacity: Int) } public enum TransitionError: Error, Equatable, Sendable { @@ -2088,13 +2081,10 @@ Tests are added before each owner is replaced. Required cases: 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 stream receiving N+1 pending events throws overflow for that - subscriber while a peer continues. Separately, mixed model-feed overflow - fails DataKit with `feedBufferOverflow(capacity:)` and does not fabricate a - domain attribution. Before attach completion the context enters `.failed` - and the waiter throws; after attach, the same state transition resets existing - results and rejects operations. Only an explicit re-attach starts a - recoverable new generation. +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. diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index ee18e112..01483bf8 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -31,6 +31,7 @@ private final class WebInspectorModelDeliveryBridge: @unchecked Sendable { proxy: WebInspectorProxy, token: UInt64 ) + case completeAttachment(token: UInt64) case prepareFailure(WebInspectorModelContext.Failure, token: UInt64) } @@ -111,6 +112,8 @@ private final class WebInspectorModelDeliveryBridge: @unchecked Sendable { 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)) } @@ -220,7 +223,6 @@ public final class WebInspectorModelContext { public enum Failure: Error, Equatable, Sendable { case connection(ConnectionFailure) case bootstrap(domain: Domain, message: String) - case feedBufferOverflow(capacity: Int) } public enum TransitionError: Error, Equatable, Sendable { @@ -753,26 +755,35 @@ public final class WebInspectorModelContext { _ = await followup.run(commit: .record(token: token)) } + let registrationWasAccepted = Mutex(false) do { - let feed = try await proxy.openModelFeed( - configuredDomains: configuredDomains - ) - guard !Task.isCancelled else { - return await closeUnacceptedFeed(feed) - } - let accepted = await accept( - feed: feed, - proxy: proxy, - token: token, - bridge: bridge + _ = 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 + } ) - if !accepted { - return await closeUnacceptedFeed(feed) - } + 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, @@ -782,17 +793,6 @@ public final class WebInspectorModelContext { } } - private nonisolated static func closeUnacceptedFeed( - _ feed: ConnectionModelFeed - ) async -> Failure? { - do { - try await feed.close() - return nil - } catch { - return mapAttachmentFailure(error) - } - } - private static func prepareAttachmentStep( token: UInt64, bridge: WebInspectorModelDeliveryBridge @@ -869,6 +869,19 @@ public final class WebInspectorModelContext { 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, @@ -932,7 +945,6 @@ public final class WebInspectorModelContext { } activeProxy = proxy activeFeed = feed - attachmentTask = nil let records = feed.records let bridge = deliveryBridge @@ -944,6 +956,14 @@ public final class WebInspectorModelContext { 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, @@ -1871,8 +1891,6 @@ public final class WebInspectorModelContext { switch feedError { case let .bootstrapFailed(domain, message): return .bootstrap(domain: Self.domain(domain), message: message) - case let .bufferOverflow(capacity): - return .feedBufferOverflow(capacity: capacity) case .connectionAlreadyUsedByDirectConsumer: return .connection(.protocolViolation( "The Proxy connection was already used outside its model feed." @@ -1905,7 +1923,9 @@ public final class WebInspectorModelContext { case .staleIdentifier: return .connection(.protocolViolation("Attachment became stale during startup.")) case let .eventBufferOverflow(capacity): - return .feedBufferOverflow(capacity: 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): diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift index c4a01627..2c80bde7 100644 --- a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -206,7 +206,6 @@ package struct ConnectionModelFeedID: Hashable, Sendable { package enum ConnectionModelFeedDeliveryResult: Sendable { case enqueued - case overflow case terminated } @@ -216,7 +215,6 @@ package enum ConnectionModelFeedError: Error, Equatable, Sendable { /// 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 bufferOverflow(capacity: Int) case consumerTerminated } @@ -260,13 +258,9 @@ package final class ConnectionModelFeedMailbox: Sendable { } } - package let capacity: Int private let state = Mutex(State()) - package init(capacity: Int) { - precondition(capacity > 0, "A bounded model feed must have a positive capacity.") - self.capacity = capacity - } + package init() {} package func claimIterator() { state.withLock { state in @@ -292,13 +286,6 @@ package final class ConnectionModelFeedMailbox: Sendable { state.waiter = nil return (.enqueued, waiter) } - guard state.pendingRecordCount < capacity else { - state.discardPendingRecords() - state.terminal = .failed( - ConnectionModelFeedError.bufferOverflow(capacity: capacity) - ) - return (.overflow, nil) - } state.pendingRecords.append(record) return (.enqueued, nil) } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index b09b84ed..5436c0de 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -590,9 +590,8 @@ package actor ConnectionCore { package func openModelFeed( configuredDomains: Set, - capacity: Int + onRegistered: (@Sendable (ConnectionModelFeed) async -> Bool)? = nil ) async throws -> ConnectionModelFeed { - precondition(capacity > 0, "A bounded model feed must have a positive capacity.") guard isOpen else { throw terminalScopeError } @@ -605,7 +604,7 @@ package actor ConnectionCore { let configuredDomains = ModelDomain.normalized(configuredDomains) let id = ConnectionModelFeedID() - let mailbox = ConnectionModelFeedMailbox(capacity: capacity) + let mailbox = ConnectionModelFeedMailbox() let feed = ConnectionModelFeed(id: id, owner: self, mailbox: mailbox) let resetGeneration: WebInspectorPage.Generation if targetRegistry.currentMainPageTargetID != nil || currentPageBindingGapIsOpen { @@ -629,14 +628,18 @@ package actor ConnectionCore { ) guard enqueueModelFeedRecord(.reset(resetGeneration)) else { - throw ConnectionModelFeedError.bufferOverflow(capacity: capacity) + throw ConnectionModelFeedError.consumerTerminated } if targetRegistry.currentMainPageTargetID != nil, !publishModelTargetSnapshot() { - throw ConnectionModelFeedError.bufferOverflow(capacity: capacity) + 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) @@ -678,15 +681,26 @@ package actor ConnectionCore { guard isOpen else { throw terminalScopeError } - guard var registration = modelFeed, + 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.") } - registration.lifecycle = .active - modelFeed = registration + 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) @@ -1988,15 +2002,6 @@ package actor ConnectionCore { switch registration.mailbox.enqueue(record) { case .enqueued: return true - case .overflow: - handoffTermination( - .modelFeedFailure( - ConnectionModelFeedError.bufferOverflow( - capacity: registration.mailbox.capacity - ) - ) - ) - return false case .terminated: handoffTermination( .modelFeedFailure(ConnectionModelFeedError.consumerTerminated) @@ -2067,11 +2072,21 @@ package actor ConnectionCore { } @discardableResult - private func publishModelSynchronizationIfReady() -> Bool { + 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 } diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index ec4ea302..bd5bb0a6 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -192,11 +192,11 @@ public actor WebInspectorProxy { package func openModelFeed( configuredDomains: Set, - capacity: Int = 256 + onRegistered: (@Sendable (ConnectionModelFeed) async -> Bool)? = nil ) async throws -> ConnectionModelFeed { try await core.openModelFeed( configuredDomains: configuredDomains, - capacity: capacity + onRegistered: onRegistered ) } diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 62b3ff12..aa3b26cc 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -128,6 +128,61 @@ func attachmentPublishesDOMSnapshotAndAcceptsFilteredSequenceGaps() async throws } } +@MainActor +@Test +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 + ) + + let replayEventCount = 512 + for index in 0..?>(nil) let openTask = Task { try await core.openModelFeed( configuredDomains: [.network], - capacity: 2 + 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(await backend.isDetached()) + #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 @@ -1308,8 +1275,7 @@ func rejectedEnableDoesNotPublishReplayMarkerOrPoisonFullFeed() async throws { )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network], - capacity: 2 + configuredDomains: [.network] ) } let enable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -1329,8 +1295,7 @@ func rejectedEnableDoesNotPublishReplayMarkerOrPoisonFullFeed() async throws { #expect(await backend.isDetached() == false) let replacement = try await core.openModelFeed( - configuredDomains: [], - capacity: 3 + configuredDomains: [] ) try await replacement.close() await core.close() @@ -1348,13 +1313,13 @@ func modelFeedActivationPageUnavailableRemainsConnectionLifecycleError() async t await backend.setSendError(WebInspectorProxyError.pageUnavailable) await #expect(throws: WebInspectorProxyError.pageUnavailable) { - try await core.openModelFeed(configuredDomains: [.network], capacity: 8) + 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: [], capacity: 3) + let replacement = try await core.openModelFeed(configuredDomains: []) try await replacement.close() await core.close() } @@ -1376,7 +1341,7 @@ func modelFeedActivationTerminalErrorIsNotRelabeledAsBootstrap( await backend.setSendError(activationError) do { - _ = try await core.openModelFeed(configuredDomains: [.network], capacity: 8) + _ = 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) @@ -1407,8 +1372,7 @@ func cancelledActivationDoesNotPublishReplayMarkerAfterOwnerStopsBeingDesired() ) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network], - capacity: 2 + configuredDomains: [.network] ) } let enable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -1427,8 +1391,7 @@ func cancelledActivationDoesNotPublishReplayMarkerAfterOwnerStopsBeingDesired() #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) let replacement = try await core.openModelFeed( - configuredDomains: [], - capacity: 3 + configuredDomains: [] ) try await replacement.close() await core.close() @@ -1456,8 +1419,7 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( ] let openTask = Task { try await core.openModelFeed( - configuredDomains: configuredDomains, - capacity: 32 + configuredDomains: configuredDomains ) } @@ -1503,8 +1465,7 @@ func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) let replacementFeed = try await core.openModelFeed( - configuredDomains: [], - capacity: 8 + configuredDomains: [] ) try await replacementFeed.close() await core.close() @@ -1521,8 +1482,7 @@ func modelFeedActivationCancellationRollsBackAfterEnableAndDisableQuiesce() asyn )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network], - capacity: 8 + configuredDomains: [.network] ) } let enable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -1541,8 +1501,7 @@ func modelFeedActivationCancellationRollsBackAfterEnableAndDisableQuiesce() asyn ]) #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) let replacementFeed = try await core.openModelFeed( - configuredDomains: [], - capacity: 8 + configuredDomains: [] ) try await replacementFeed.close() await core.close() @@ -1575,7 +1534,7 @@ func modelFeedCloseKeepsClaimUntilDisableAndSharesDuplicateCloseCompletion() asy duplicateClose.cancel() await #expect(throws: ConnectionModelFeedError.alreadyOpen) { - try await core.openModelFeed(configuredDomains: [], capacity: 8) + try await core.openModelFeed(configuredDomains: []) } await #expect(throws: WebInspectorProxyError.connectionInUse) { try await core.send(ProtocolCommand( @@ -1592,8 +1551,7 @@ func modelFeedCloseKeepsClaimUntilDisableAndSharesDuplicateCloseCompletion() asy try await feed.close() let replacementFeed = try await core.openModelFeed( - configuredDomains: [], - capacity: 8 + configuredDomains: [] ) try await replacementFeed.close() @@ -1623,8 +1581,7 @@ func modelFeedRetargetDuringAcquisitionMovesPendingCapabilityToReplacement() asy )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network], - capacity: 16 + configuredDomains: [.network] ) } let oldEnable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -1806,7 +1763,7 @@ func domBootstrapRequestsMainAndCommittedFramesInSnapshotOrder() async throws { parentFrameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 16) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) let mainDocument = try await backend.waitForTargetMessage( method: "DOM.getDocument", ordinal: 0 @@ -1868,7 +1825,7 @@ func domDocumentInvalidationPrecedesMainTargetDeltasAndFreshBootstrap() async th )) let openTask = Task { - try await core.openModelFeed(configuredDomains: [.css], capacity: 24) + try await core.openModelFeed(configuredDomains: [.css]) } let staleDocument = try await backend.waitForTargetMessage( method: "DOM.getDocument", @@ -1985,7 +1942,7 @@ func frameDocumentUpdatedBypassesPublicFilterWithOneModelInvalidationBoundary() parentFrameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 24) + 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()) @@ -2104,7 +2061,7 @@ func domBootstrapTracksTargetsAddedAndDestroyedBeforeInitialSync() async throws frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 24) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) let mainDocument = try await backend.waitForTargetMessage( method: "DOM.getDocument", ordinal: 0 @@ -2241,7 +2198,7 @@ func pendingDOMBootstrapReplyFromSupersededBindingCannotPublish() async throws { type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 12) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) let oldDocument = try await backend.waitForTargetMessage( method: "DOM.getDocument", ordinal: 0 @@ -2281,115 +2238,6 @@ func pendingDOMBootstrapReplyFromSupersededBindingCannotPublish() async throws { await core.close() } -@Test -func domBootstrapSnapshotOverflowCannotAdvanceToNextTarget() 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], capacity: 2) - let mainDocument = try await backend.waitForTargetMessage( - method: "DOM.getDocument", - ordinal: 0 - ) - await modelFeedRespondWithDocument(to: mainDocument, core: core) - - var iterator = feed.records.makeAsyncIterator() - await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { - try await iterator.next() - } - #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) - await #expect(throws: WebInspectorProxyError.self) { - try await core.waitUntilClosed() - } - let getDocumentMessages = try await backend.sentTargetMessages().filter { - try modelFeedMessageMethod($0.message) == "DOM.getDocument" - } - #expect(getDocumentMessages.map(\.targetIdentifier) == [ - ProtocolTarget.ID("page-main"), - ]) -} - -@Test -func domBootstrapCompletionOverflowTerminatesAtTheBoundary() 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], capacity: 2) - let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") - var iterator = feed.records.makeAsyncIterator() - _ = try await modelFeedRequireReset(iterator.next()) - let waitingCommand = Task { - try await core.send(modelFeedCommand( - domain: .page, - method: "Page.reload", - authority: ConnectionModelCommandAuthorization( - feedID: feed.id, - generation: try await core.pageGeneration() - ) - )) - } - await core.waitForModelCommandReadinessWaiterCountForTesting(1) - - // targetSnapshot remains queued. bootstrapSnapshot fills the second slot, - // so bootstrapComplete is the exact overflowing publication. - await modelFeedRespondWithDocument(to: getDocument, core: core) - await #expect(throws: WebInspectorProxyError.self) { - try await core.waitUntilClosed() - } - await #expect(throws: TransportSession.Error.self) { - try await waitingCommand.value - } - #expect(await core.modelCommandOwnerCountForTesting() == 0) - #expect(await core.modelCommandReadinessWaiterCountForTesting() == 0) - #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) - await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { - try await iterator.next() - } - #expect(try await modelFeedDOMGetDocumentMessages(backend).count == 1) -} - -@Test -func domSynchronizationOverflowTerminatesAtTheBoundary() 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], capacity: 2) - let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") - var iterator = feed.records.makeAsyncIterator() - _ = try await modelFeedRequireReset(iterator.next()) - _ = try await modelFeedRequireTargetSnapshot(iterator.next()) - - // The accepted snapshot and bootstrapComplete fill the mailbox, making - // synchronizationComplete the exact overflowing publication. - await modelFeedRespondWithDocument(to: getDocument, core: core) - await #expect(throws: WebInspectorProxyError.self) { - try await core.waitUntilClosed() - } - #expect(await core.terminalCause == .modelFeedFailure(.bufferOverflow(capacity: 2))) - await #expect(throws: ConnectionModelFeedError.bufferOverflow(capacity: 2)) { - try await iterator.next() - } - #expect(try await modelFeedDOMGetDocumentMessages(backend).count == 1) -} - @Test func terminatedDOMFeedCannotAdvanceBootstrapQueue() async throws { let backend = FakeTransportBackend() @@ -2406,8 +2254,7 @@ func terminatedDOMFeedCannotAdvanceBootstrapQueue() async throws { parentFrameID: "main-frame" )) var feed: ConnectionModelFeed? = try await core.openModelFeed( - configuredDomains: [.dom], - capacity: 8 + configuredDomains: [.dom] ) let records = try #require(feed).records let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") @@ -2434,7 +2281,7 @@ func requiredDOMBootstrapFailureTerminatesTheFeed() async throws { type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 8) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) var iterator = feed.records.makeAsyncIterator() _ = try await modelFeedRequireReset(iterator.next()) _ = try await modelFeedRequireTargetSnapshot(iterator.next()) @@ -2522,7 +2369,7 @@ func malformedRequiredDOMBootstrapReplyTerminatesWithoutTaskLeak() async throws type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 8) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) var iterator = feed.records.makeAsyncIterator() _ = try await modelFeedRequireReset(iterator.next()) _ = try await modelFeedRequireTargetSnapshot(iterator.next()) @@ -2559,7 +2406,7 @@ func closingDOMModelFeedCancelsAndAwaitsPendingBootstrap() async throws { type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 8) + 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()) @@ -2570,7 +2417,7 @@ func closingDOMModelFeedCancelsAndAwaitsPendingBootstrap() async throws { #expect(try await iterator.next() == nil) #expect(await backend.isDetached() == false) - let replacement = try await core.openModelFeed(configuredDomains: [], capacity: 3) + let replacement = try await core.openModelFeed(configuredDomains: []) var replacementIterator = replacement.records.makeAsyncIterator() _ = try await modelFeedRequireReset(replacementIterator.next()) _ = try await modelFeedRequireTargetSnapshot(replacementIterator.next()) @@ -2629,8 +2476,7 @@ func rollingBackModelFeedDoesNotReadmitDOMBootstrapDuringCommit() async throws { )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.dom, .network, .console], - capacity: 24 + configuredDomains: [.dom, .network, .console] ) } let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") @@ -2679,8 +2525,7 @@ func terminalDuringModelFeedRollbackCompletesWithoutLeakingClaim() async throws )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network, .console], - capacity: 16 + configuredDomains: [.network, .console] ) } let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -2714,8 +2559,7 @@ func explicitCloseDuringModelFeedRollbackLetsTerminalOwnerRetireRegistration() a )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network, .console], - capacity: 16 + configuredDomains: [.network, .console] ) } let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -2752,8 +2596,7 @@ func modelFeedRollbackDisableRejectionTerminatesInsteadOfReusingEnabledState() a )) let openTask = Task { try await core.openModelFeed( - configuredDomains: [.network, .console], - capacity: 16 + configuredDomains: [.network, .console] ) } let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") @@ -2794,7 +2637,7 @@ func modelFeedRollbackDisableRejectionTerminatesInsteadOfReusingEnabledState() a #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) #expect(await backend.isDetached()) await #expect(throws: WebInspectorProxyError.self) { - try await core.openModelFeed(configuredDomains: [], capacity: 8) + try await core.openModelFeed(configuredDomains: []) } } @@ -2859,8 +2702,7 @@ func modelBindingCommandWaitsForInitialSynchronizationBeforeWire() async throws let openTask = Task { try await core.openModelFeed( - configuredDomains: [.dom, .network], - capacity: 16 + configuredDomains: [.dom, .network] ) } let bootstrap = try await backend.waitForTargetMessage(method: "DOM.getDocument") @@ -3082,7 +2924,7 @@ func modelAuthorityRejectsForeignUnconfiguredAndConnectionOwnedCommandsWithoutWi type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [], capacity: 8) + let feed = try await core.openModelFeed(configuredDomains: []) let authorization = ConnectionModelCommandAuthorization( feedID: feed.id, generation: try await core.pageGeneration(), @@ -3149,7 +2991,7 @@ func modelPageHandlePropagatesAuthorizationThroughTypedDomainDispatch() async th type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [], capacity: 8) + let feed = try await core.openModelFeed(configuredDomains: []) let proxy = try await WebInspectorProxy(transport: core) let modelPage = WebInspectorPage( proxy: proxy, @@ -3272,7 +3114,7 @@ func retargetFailsModelCommandsForOldMainAndFrameBinding() async throws { frameID: "main-frame", isProvisional: true )) - let feed = try await core.openModelFeed(configuredDomains: [], capacity: 16) + let feed = try await core.openModelFeed(configuredDomains: []) let authorization = ConnectionModelCommandAuthorization( feedID: feed.id, generation: try await core.pageGeneration() @@ -3330,7 +3172,7 @@ func generationReadinessWaiterDoesNotChaseReplacementSynchronization() async thr type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [.dom], capacity: 24) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) let oldBootstrap = try await backend.waitForTargetMessage(method: "DOM.getDocument") let oldAuthorization = ConnectionModelCommandAuthorization( feedID: feed.id, @@ -3396,7 +3238,7 @@ func terminalCloseDrainsPendingModelCommandTasks() async throws { type: "page", frameID: "main-frame" )) - let feed = try await core.openModelFeed(configuredDomains: [], capacity: 8) + let feed = try await core.openModelFeed(configuredDomains: []) let authorization = ConnectionModelCommandAuthorization( feedID: feed.id, generation: try await core.pageGeneration() @@ -3432,8 +3274,7 @@ func readinessWaitingModelCommandRunnerDoesNotKeepCoreAlive() async throws { )) var feed: ConnectionModelFeed? = try await core?.openModelFeed( - configuredDomains: [.dom], - capacity: 8 + configuredDomains: [.dom] ) _ = try await backend.waitForTargetMessage(method: "DOM.getDocument") let authorization = ConnectionModelCommandAuthorization( @@ -3472,8 +3313,7 @@ func replyWaitingModelCommandRunnerDoesNotKeepCoreAlive() async throws { )) var feed: ConnectionModelFeed? = try await core?.openModelFeed( - configuredDomains: [], - capacity: 8 + configuredDomains: [] ) let authorization = ConnectionModelCommandAuthorization( feedID: try #require(feed?.id), @@ -3637,8 +3477,7 @@ private func modelFeedOpenSuccessfully( let targetMessageCount = await backend.sentTargetMessages().count let openTask = Task { try await core.openModelFeed( - configuredDomains: configuredDomains, - capacity: 32 + configuredDomains: configuredDomains ) } if normalizedDomains.contains(.dom) { From 518620062714f2eacc51ab91480b12b2464b37e8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:33:40 +0900 Subject: [PATCH 78/81] fix(css)!: preserve observable declaration identity Make each CSS declaration an actor-confined observable model, serialize refresh and mutation work, and update only the submitted property during a toggle. Keep collection snapshots topology-only, rebind visible rows directly when positional backend IDs are reused, and reject stale declaration handles after structural edits. BREAKING CHANGE: CSSStyleProperty is now an observable reference type, and CSSStyleSection, CSSStyle, and CSSStyleRule are no longer Sendable. --- Docs/WebInspectorKitsArchitecture.md | 59 ++++ .../WebInspectorDataKit/CSSStyleModels.swift | 88 +++-- Sources/WebInspectorDataKit/CSSStyles.swift | 268 +++++++++++++-- .../WebInspectorDataKit/DOMStateStore.swift | 4 +- .../WebInspectorModelContext.swift | 88 +++-- .../DOMElementStylePresentationModel.swift | 118 +++---- ...OMElementStylePropertyCollectionCell.swift | 35 +- .../Element/DOMElementStylePropertyView.swift | 68 ++-- .../Element/DOMElementViewController.swift | 54 +-- .../WebInspectorUIDOMLog.swift | 4 + .../WebInspectorDataKitTests.swift | 321 ++++++++++++++++++ .../DOMContainerTests.swift | 19 +- ...OMElementStylePresentationStateTests.swift | 106 ++++-- 13 files changed, 979 insertions(+), 253 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 76756ed7..5d41f412 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1353,6 +1353,65 @@ A visible UIKit/custom consumer observes that phase and calls the awaited 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. + +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 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 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 a408ee6c..55e577f7 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,71 @@ private struct CSSPropertyInspectorBaseline: Equatable { } } +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 { @@ -70,6 +137,7 @@ public final class CSSStyles: WebInspectorPersistentModel { @ObservationIgnored weak var modelContext: WebInspectorModelContext? @ObservationIgnored private var inspectorBaselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] + @ObservationIgnored private let operationGate = CSSStylesOperationGate() init(nodeID: DOMNode.ID, modelContext: WebInspectorModelContext) { id = ID(nodeID: nodeID) @@ -89,8 +157,13 @@ public final class CSSStyles: WebInspectorPersistentModel { inlineStyles: CSS.InlineStyles, computedProperties: [CSS.ComputedProperty] ) { - sections = CSSStyleSectionBuilder.makeSections(matched: matchedStyles, inline: inlineStyles) + let rawSections = CSSStyleSectionBuilder.makeSections(matched: matchedStyles, inline: inlineStyles) + invalidateInspectorBaselines( + for: styleIDsWithChangedPropertyTopology(in: rawSections) + ) + let incomingSections = rawSections .map { section($0, replacingStyleWith: applyingInspectorBaselines(to: $0.proxyStyle)) } + sections = reconciledSections(incomingSections) self.computedProperties = computedProperties.map(CSSComputedProperty.init) phase = .loaded } @@ -117,9 +190,9 @@ public final class CSSStyles: WebInspectorPersistentModel { /// 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 +203,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 +216,9 @@ public final class CSSStyles: WebInspectorPersistentModel { return SetStyleTextIntent(styleID: style.id, text: text) } - func contains(propertyID: CSSStyleProperty.ID) -> Bool { + func contains(property: CSSStyleProperty) -> Bool { sections.contains { section in - section.style.properties.contains { $0.id == propertyID } + section.style.properties.contains { $0 === property } } } @@ -153,9 +226,9 @@ public final class CSSStyles: WebInspectorPersistentModel { sections.contains { $0.rule?.id == ruleID } } - func setDeclarationTextIntent(for propertyID: CSSStyleProperty.ID, text replacementText: String) -> SetStyleTextIntent? { + 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] @@ -166,8 +239,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, @@ -184,31 +257,70 @@ 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) + inspectorBaselines[propertyID] = CSSPropertyInspectorBaseline( + 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 { + invalidateInspectorBaselines( + for: styleIDsWithChangedPropertyTopology(in: updatedSections) + ) + let normalizedSections = updatedSections.map { + section($0, replacingStyleWith: applyingInspectorBaselines(to: $0.proxyStyle)) + } + 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() + throw error + } + } + + 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) @@ -229,6 +341,121 @@ public final class CSSStyles: WebInspectorPersistentModel { ) } + 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 + } + return CSSStyleSection( + id: section.id, + kind: section.kind, + title: section.title, + rule: section.proxyRule, + style: section.proxyStyle, + isEditable: section.isEditable, + propertyModels: properties + ) + } + } + + 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 invalidateInspectorBaselines(for styleIDs: Set) { + guard styleIDs.isEmpty == false else { + return + } + inspectorBaselines = inspectorBaselines.filter { _, baseline in + styleIDs.contains(baseline.styleID) == false + } + } + + private func propertiesByStyleID( + 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 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 + } + private func applyingInspectorBaselines(to style: CSS.Style) -> CSS.Style { guard inspectorBaselines.isEmpty == false else { return style @@ -239,7 +466,10 @@ public final class CSSStyles: WebInspectorPersistentModel { guard let baseline = inspectorBaselines[propertyID] else { return property } - let isModified = CSSPropertyInspectorBaseline(property) != baseline + let isModified = CSSPropertyInspectorBaseline( + styleID: style.id, + property: property + ) != baseline if isModified == false { inspectorBaselines[propertyID] = nil } diff --git a/Sources/WebInspectorDataKit/DOMStateStore.swift b/Sources/WebInspectorDataKit/DOMStateStore.swift index 799ad741..e53f5d4e 100644 --- a/Sources/WebInspectorDataKit/DOMStateStore.swift +++ b/Sources/WebInspectorDataKit/DOMStateStore.swift @@ -94,10 +94,10 @@ package final class DOMStateStore { } package func styles( - containing propertyID: CSSStyleProperty.ID + containing property: CSSStyleProperty ) -> CSSStyles? { nodesByID.values.lazy.compactMap(\.elementStyles).first { - $0.contains(propertyID: propertyID) + $0.contains(property: property) } } diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 01483bf8..4210546d 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -3059,6 +3059,15 @@ extension WebInspectorModelContext { 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() @@ -3094,20 +3103,29 @@ extension WebInspectorModelContext { undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMUndoCapability? { try requireConfigured(.css) - guard let styles = domState.styles(containing: property.id), - let intent = styles.setStyleTextIntent( - for: property.id, - enabled: enabled - ) else { + guard let styles = domState.styles(containing: property), + property.beginMutation() else { throw WebInspectorModelError.staleModel } - let target = try cssTarget(owning: intent.styleID) - let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - let options = DOMMutationPolicy(undo: undo) - domState.recordEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - styles.applySetStyleText(result: result, for: property.id) - return makeDOMUndoCapability(policy: undo) + 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( @@ -3116,20 +3134,42 @@ extension WebInspectorModelContext { undo: WebInspectorUndoPolicy = .automatic ) async throws -> DOMUndoCapability? { try requireConfigured(.css) - guard let styles = domState.styles(containing: property.id), - let intent = styles.setDeclarationTextIntent( - for: property.id, - text: text - ) else { + guard let styles = domState.styles(containing: property), + property.beginMutation() else { throw WebInspectorModelError.staleModel } - let target = try cssTarget(owning: intent.styleID) - let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - let options = DOMMutationPolicy(undo: undo) - domState.recordEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - styles.applySetStyleText(result: result, for: property.id) - return makeDOMUndoCapability(policy: undo) + 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( 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 0d1ac741..5ff0bd55 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift @@ -1,6 +1,7 @@ #if canImport(UIKit) import WebInspectorUIBase import WebInspectorDataKit +import ObservationBridge import UIKit @MainActor @@ -12,6 +13,7 @@ package final class DOMElementStylePropertyView: UIView { 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) @@ -23,31 +25,38 @@ package final class DOMElementStylePropertyView: UIView { nil } + isolated deinit { + propertyObservation?.cancel() + } + package func bind( property: CSSStyleProperty, onToggle: ToggleAction? = nil ) { - if self.property?.id != property.id { + if self.property !== property { // A submitted mutation is committed user work and outlives cell // reuse. Drop only this view's completion handle. toggleTask = nil } - render(property: property, onToggle: onToggle) - } - - package func render( - property: CSSStyleProperty, - onToggle: ToggleAction? = 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 @@ -107,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) } @@ -148,7 +159,7 @@ package final class DOMElementStylePropertyView: UIView { toggleTask = Task { @MainActor [weak self] in _ = await toggleAction(property, requestedEnabledState) guard let self, - self.property?.id == property.id else { + self.property === property else { return } toggleTask = nil @@ -159,7 +170,10 @@ package final class DOMElementStylePropertyView: UIView { } private func canToggle(_ property: CSSStyleProperty) -> Bool { - property.isEditable && toggleAction != nil && toggleTask == nil + property.isEditable + && property.isMutationPending == false + && toggleAction != nil + && toggleTask == nil } private func declarationText(for property: CSSStyleProperty) -> NSAttributedString { @@ -228,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) } @@ -246,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.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index a66892b9..263e4ab3 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -301,19 +301,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) @@ -325,32 +321,23 @@ package final class DOMElementViewController: UICollectionViewController { let shouldAnimateSnapshot = animated #endif dataSource.apply(snapshot, animatingDifferences: shouldAnimateSnapshot) { [weak self] in + self?.applyVisibleBindings(update) #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 -#if DEBUG - self?.finishStyleRenderForTesting() -#endif - } } } + private func applyVisibleBindings( + _ update: DOMElementStyleSnapshotCoordinator.SnapshotUpdate + ) { + if update.rebindVisiblePropertyRows { + rebindVisiblePropertyRows() + } + rebindVisibleHeaders(update.updatedSectionIDs) + } + private func applyPlaceholder(_ placeholderMode: DOMElementStyleSnapshotCoordinator.PlaceholderMode) { switch placeholderMode { case .none: @@ -397,6 +384,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) } @@ -428,6 +428,10 @@ package final class DOMElementViewController: UICollectionViewController { ) return true } catch { + WebInspectorUIDOMLog.error( + "CSS property toggle failed name=\(property.name) " + + "id=\(property.id.rawValue): \(String(describing: error))" + ) return false } } 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/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index aa3b26cc..f110f8dc 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -551,6 +551,191 @@ func runtimeGroupCleansUpWhenTheOperationIsCancelled() async throws { } } +@MainActor +@Test +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 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) + + 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 + ) + + 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 cssTopologyChangePreservesInspectorBaselineForUnrelatedStyle() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("baseline-node")), + modelContext: context + ) + let editedBefore = cssBaselineTestStyle( + id: "edited-style", + properties: [("color", "red")] + ) + let unrelatedBefore = cssBaselineTestStyle( + id: "unrelated-style", + properties: [("margin", "0")] + ) + styles.load( + matchedStyles: cssBaselineMatchedStyles([editedBefore, unrelatedBefore]), + inlineStyles: .init(), + computedProperties: [] + ) + let editedProperty = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "color" } + ) + + let editedAfter = cssBaselineTestStyle( + id: "edited-style", + properties: [("color", "blue")] + ) + styles.applySetStyleText(result: editedAfter, for: editedProperty.id) + #expect(editedProperty.isModifiedByInspector) + + let unrelatedAfter = cssBaselineTestStyle( + id: "unrelated-style", + properties: [("display", "block"), ("margin", "0")] + ) + styles.load( + matchedStyles: cssBaselineMatchedStyles([editedAfter, unrelatedAfter]), + inlineStyles: .init(), + computedProperties: [] + ) + + #expect(editedProperty.isModifiedByInspector) + #expect( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "color" } === editedProperty + ) +} + private struct AttachedModelFixture { let runtime: DataKitTestRuntime let target: WebInspectorTarget @@ -558,6 +743,142 @@ private struct AttachedModelFixture { let configuration: WebInspectorModelContext.Configuration } +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 + ) +} + +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 + ), + ]) +} + +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 + ) +} + +private func cssBaselineTestStyle( + id: String, + properties: [(name: String, value: String)] +) -> CSS.Style { + CSS.Style( + id: CSS.Style.ID(id), + properties: properties.enumerated().map { index, property in + CSS.Property( + id: CSS.Property.ID("\(id)\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 + ) +} + +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 + ) + } + ) +} + +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()) + ) + await wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([]) + ) +} + @MainActor private func withAttachedModelContext( configuration: WebInspectorModelContext.Configuration, diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index c703b2cc..f1b0dad9 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -160,12 +160,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,6 +284,8 @@ struct DOMContainerTests { .contains("margin: 0;") } #expect(didRenderBodyRows) + let applyCountBeforeSelection = viewController.styleSnapshotApplyCountForTesting + let cellIDsBeforeSelection = visibleCellIDs(in: viewController) try context.selectDOMNode(input) applyBodyStyles( @@ -303,7 +302,8 @@ struct DOMContainerTests { } #expect(didRenderInputRows) - #expect(viewController.lastSnapshotApplyModeForTesting == .reloadData) + #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeSelection) + #expect(visibleCellIDs(in: viewController) == cellIDsBeforeSelection) } @Test @@ -323,6 +323,8 @@ struct DOMContainerTests { } window.layoutIfNeeded() #expect(didRenderBodyRows) + let applyCountBeforeSelection = viewController.styleSnapshotApplyCountForTesting + let cellIDsBeforeSelection = visibleCellIDs(in: viewController) let body = try #require(try context.selectedDOMNode) let input = try selectElement(named: "input", in: context) @@ -366,7 +368,8 @@ struct DOMContainerTests { #expect(didRenderInputRows) #expect(viewController.collectionView.isHidden == false) - #expect(viewController.lastSnapshotApplyModeForTesting == .reloadData) + #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeSelection) + #expect(visibleCellIDs(in: viewController) == cellIDsBeforeSelection) } @Test diff --git a/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift b/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift index 0322a474..2d5bc580 100644 --- a/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift +++ b/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift @@ -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, From 0955bd5af73a4e4cd60e26027dcd364cdf6ea707 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:49:04 +0900 Subject: [PATCH 79/81] fix(css): preserve shared inspector edit state --- Docs/WebInspectorKitsArchitecture.md | 28 ++ Sources/WebInspectorDataKit/CSSStyles.swift | 240 +++++++---- .../WebInspectorModelContext.swift | 18 +- .../Element/DOMElementViewController.swift | 71 +++- .../WebInspectorDataKitTests.swift | 391 +++++++++++++++++- .../DOMContainerTests.swift | 21 + 6 files changed, 685 insertions(+), 84 deletions(-) diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md index 5d41f412..007bc955 100644 --- a/Docs/WebInspectorKitsArchitecture.md +++ b/Docs/WebInspectorKitsArchitecture.md @@ -1385,6 +1385,23 @@ 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 @@ -1392,6 +1409,17 @@ 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, diff --git a/Sources/WebInspectorDataKit/CSSStyles.swift b/Sources/WebInspectorDataKit/CSSStyles.swift index 55e577f7..b3ac4052 100644 --- a/Sources/WebInspectorDataKit/CSSStyles.swift +++ b/Sources/WebInspectorDataKit/CSSStyles.swift @@ -23,6 +23,143 @@ 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 @@ -136,7 +273,8 @@ public final class CSSStyles: WebInspectorPersistentModel { public private(set) var computedProperties: [CSSComputedProperty] @ObservationIgnored weak var modelContext: WebInspectorModelContext? - @ObservationIgnored private var inspectorBaselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] + @ObservationIgnored private let inspectorBaselineStore: CSSInspectorBaselineStore + @ObservationIgnored private var hasCompletedLoad: Bool @ObservationIgnored private let operationGate = CSSStylesOperationGate() init(nodeID: DOMNode.ID, modelContext: WebInspectorModelContext) { @@ -144,7 +282,8 @@ public final class CSSStyles: WebInspectorPersistentModel { phase = .loading sections = [] computedProperties = [] - inspectorBaselines = [:] + inspectorBaselineStore = modelContext.cssInspectorBaselineStore + hasCompletedLoad = false self.modelContext = modelContext } @@ -158,13 +297,11 @@ public final class CSSStyles: WebInspectorPersistentModel { computedProperties: [CSS.ComputedProperty] ) { let rawSections = CSSStyleSectionBuilder.makeSections(matched: matchedStyles, inline: inlineStyles) - invalidateInspectorBaselines( - for: styleIDsWithChangedPropertyTopology(in: rawSections) - ) let incomingSections = rawSections - .map { section($0, replacingStyleWith: applyingInspectorBaselines(to: $0.proxyStyle)) } + .map { section($0, replacingStyleWith: inspectorBaselineStore.applyingBaselines(to: $0.proxyStyle)) } sections = reconciledSections(incomingSections) self.computedProperties = computedProperties.map(CSSComputedProperty.init) + hasCompletedLoad = true phase = .loaded } @@ -175,17 +312,21 @@ 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, @@ -264,9 +405,9 @@ public final class CSSStyles: WebInspectorPersistentModel { .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( + if let property = section.proxyStyle.properties.first(where: { $0.id == propertyID.proxyID }) { + inspectorBaselineStore.recordIfNeeded( + propertyID: propertyID, styleID: section.proxyStyle.id, property: property ) @@ -283,11 +424,18 @@ public final class CSSStyles: WebInspectorPersistentModel { didRewriteSection = true } if didRewriteSection { - invalidateInspectorBaselines( - for: styleIDsWithChangedPropertyTopology(in: updatedSections) + inspectorBaselineStore.reconcile( + styleIDs: styleIDsWithChangedPropertyTopology(in: updatedSections), + incomingSections: updatedSections ) let normalizedSections = updatedSections.map { - section($0, replacingStyleWith: applyingInspectorBaselines(to: $0.proxyStyle)) + section( + $0, + replacingStyleWith: inspectorBaselineStore.applyingBaselines( + to: $0.proxyStyle, + clearsRestoredBaselines: true + ) + ) } sections = reconciledSections( normalizedSections, @@ -310,10 +458,18 @@ public final class CSSStyles: WebInspectorPersistentModel { 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( @@ -413,31 +569,10 @@ public final class CSSStyles: WebInspectorPersistentModel { }) } - private func invalidateInspectorBaselines(for styleIDs: Set) { - guard styleIDs.isEmpty == false else { - return - } - inspectorBaselines = inspectorBaselines.filter { _, baseline in - styleIDs.contains(baseline.styleID) == false - } - } - private func propertiesByStyleID( 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 + cssPropertiesByStyleID(in: sections) } private func hasEqualDeclarationContent( @@ -456,37 +591,4 @@ public final class CSSStyles: WebInspectorPersistentModel { && lhs.isModifiedByInspector == rhs.isModifiedByInspector } - 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 - } - let isModified = CSSPropertyInspectorBaseline( - styleID: style.id, - property: 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 style - } } diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index 4210546d..fbb69872 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -458,6 +458,7 @@ public final class WebInspectorModelContext { 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 @@ -524,6 +525,7 @@ public final class WebInspectorModelContext { public init(configuration: Configuration = .init()) { configuredDomains = configuration.domains + cssInspectorBaselineStore = CSSInspectorBaselineStore() domState = DOMStateStore() runtimeState = RuntimeStateStore() networkRequests = NetworkRequestStore() @@ -1248,9 +1250,7 @@ public final class WebInspectorModelContext { private func prepareSemanticReset( ) -> ReducerWork { domState.advanceDocumentEpoch() - applyDOMStateEffects( - domState.resetDocument() - ) + resetDOM() runtimeState.reset() let consoleReset = consoleMessages.prepareClearForLifecycle( modelContext: self @@ -1392,10 +1392,9 @@ public final class WebInspectorModelContext { self.binding = binding if target.id == binding.currentPageID { domState.advanceDocumentEpoch() - applyDOMStateEffects( - domState.resetDocument() - ) + resetDOM() } else if let frameID = target.frameID { + cssInspectorBaselineStore.reset(targetID: target.id) applyDOMStateEffects( domState.detachProjectedFrameDocument( forFrameID: frameID @@ -1575,6 +1574,7 @@ public final class WebInspectorModelContext { )?.work } binding.domAuthority.removeValue(forKey: target.id) + cssInspectorBaselineStore.reset(targetID: target.id) if let frameID = target.frameID { applyDOMStateEffects( domState.detachProjectedFrameDocument( @@ -1585,6 +1585,7 @@ public final class WebInspectorModelContext { 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( @@ -3008,6 +3009,7 @@ extension WebInspectorModelContext { } private func resetDOM() { + cssInspectorBaselineStore.reset() let effects = domState.resetDocument() applyDOMStateEffects(effects) } @@ -3083,6 +3085,10 @@ extension WebInspectorModelContext { 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 diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index 263e4ab3..11385c40 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -13,6 +13,8 @@ package final class DOMElementViewController: UICollectionViewController { 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() @@ -49,6 +51,7 @@ package final class DOMElementViewController: UICollectionViewController { resolveStyleRenderWaitersForTesting(result: false) #endif styleHydrationTask?.cancel() + selectedStylesRenderTask?.cancel() statusTask?.cancel() selectedStylesObservation?.cancel() } @@ -72,6 +75,7 @@ package final class DOMElementViewController: UICollectionViewController { super.viewIsAppearing(animated) isStyleHydrationActive = true if let selectedNode = try? context.selectedDOMNode { + scheduleSelectedStylesRender(for: selectedNode) hydrateStylesIfNeeded(for: selectedNode, retryFailure: true) } } @@ -79,6 +83,7 @@ package final class DOMElementViewController: UICollectionViewController { override package func viewDidDisappear(_ animated: Bool) { isStyleHydrationActive = false cancelStyleHydration() + cancelSelectedStylesRender() super.viewDidDisappear(animated) } @@ -207,6 +212,7 @@ package final class DOMElementViewController: UICollectionViewController { hasBoundSelectedNode = true observedSelectedNodeObjectID = nodeObjectID cancelStyleHydration() + cancelSelectedStylesRender() selectedStylesObservation?.cancel() guard let node else { selectedStylesObservation = nil @@ -222,8 +228,9 @@ package final class DOMElementViewController: UICollectionViewController { self.renderSelectedStyles(nil) return } - self.renderSelectedStyles(node.elementStyles) - if node.elementStyles?.phase == .needsRefresh { + let phase = self.sampleSelectedStylesDependencies(node.elementStyles) + self.scheduleSelectedStylesRender(for: node) + if phase == .needsRefresh { self.hydrateStylesIfNeeded(for: node, retryFailure: false) } } @@ -267,8 +274,13 @@ package final class DOMElementViewController: UICollectionViewController { styleHydrationTask = Task { @MainActor [weak self] in do { try await operation() + } catch is CancellationError { + // Selection and visibility changes cancel obsolete hydration. } catch { - // CSSStyles owns and publishes the failed phase. + WebInspectorUIDOMLog.error( + "CSS style hydration failed nodeID=\(String(describing: node.id)): " + + String(describing: error) + ) } guard let self, styleHydrationGeneration == generation else { @@ -288,9 +300,56 @@ package final class DOMElementViewController: UICollectionViewController { styleHydrationGeneration += 1 } - /// 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. + /// 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 + } + + 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()) diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index f110f8dc..56b4feac 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -684,6 +684,107 @@ func cssPropertyIdentitySurvivesRefreshAndQueuedMutationTouchesOnlySubmittedProp } } +@MainActor +@Test +func cancellingCSSLoadingRestoresRefreshableResourcePhase() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("cancelled-node")), + modelContext: context + ) + + styles.cancelLoading() + #expect(styles.phase == .unavailable) + + styles.load( + matchedStyles: .init(), + inlineStyles: .init(), + computedProperties: [] + ) + styles.markLoading() + styles.cancelLoading() + + #expect(styles.phase == .needsRefresh) + #expect(styles.sections.isEmpty) +} + +@MainActor +@Test +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() + + let queued = Task { + try await styles.withExclusiveOperation {} + } + await Task.yield() + queued.cancel() + + await #expect(throws: CancellationError.self) { + try await queued.value + } + #expect(styles.phase == .loading) + + releaseHolder.open() + try await holder.value +} + +@MainActor +@Test +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 + ) + + load.cancel() + gate.open() + + await #expect(throws: CancellationError.self) { + try await load.value + } + #expect(body.elementStyles?.phase == .unavailable) + } +} + @MainActor @Test func cssTopologyChangePreservesInspectorBaselineForUnrelatedStyle() throws { @@ -736,6 +837,282 @@ func cssTopologyChangePreservesInspectorBaselineForUnrelatedStyle() throws { ) } +@MainActor +@Test +func cssTopologyExpansionRekeysUniqueInspectorBaselinesWithinStyle() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("expanded-baseline-node")), + modelContext: context + ) + let initialStyle = cssBaselineTestStyle( + id: "expanded-style", + properties: [("inset", "0"), ("height", "100%"), ("opacity", "0")] + ) + styles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + + let height = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "height" } + ) + let heightEditedStyle = cssBaselineTestStyle( + id: "expanded-style", + properties: [("inset", "0"), ("height", "50%"), ("opacity", "0")] + ) + styles.applySetStyleText(result: heightEditedStyle, for: height.id) + #expect(height.isModifiedByInspector) + + let inset = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "inset" } + ) + 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) + + let editedProperties = styles.sections.flatMap(\.style.properties) + #expect(editedProperties.first { $0.name == "inset" }?.isModifiedByInspector == true) + #expect(editedProperties.first { $0.name == "height" }?.isModifiedByInspector == true) + + 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) +} + +@MainActor +@Test +func sharedRuleInspectorBaselineFollowsStyleAcrossDOMNodes() throws { + let context = WebInspectorModelContext.preview() + let firstStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("first-shared-style-node")), + modelContext: context + ) + 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 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) + + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([editedStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + let secondPadding = try #require( + secondStyles.sections + .flatMap(\.style.properties) + .first { $0.name == "padding" } + ) + #expect(secondPadding !== firstPadding) + #expect(secondPadding.status == .disabled) + #expect(secondPadding.isModifiedByInspector) + + secondStyles.applySetStyleText(result: initialStyle, for: secondPadding.id) + #expect(secondPadding.status == .active) + #expect(secondPadding.isModifiedByInspector == false) + + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + #expect( + firstStyles.sections + .flatMap(\.style.properties) + .first { $0.name == "padding" }? + .isModifiedByInspector == false + ) +} + +@MainActor +@Test +func staleSharedRuleLoadCannotRetireInspectorBaseline() throws { + let context = WebInspectorModelContext.preview() + let editedStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("edited-shared-style-node")), + modelContext: context + ) + let staleStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("stale-shared-style-node")), + modelContext: context + ) + let initialStyle = cssBaselineTestStyle( + id: "stale-shared-style", + properties: [("color", "red")] + ) + editedStyles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + 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 staleResponseStyle = cssBaselineTestStyle( + id: "stale-shared-style", + properties: [("display", "block"), ("color", "red")] + ) + staleStyles.load( + matchedStyles: cssBaselineMatchedStyles([staleResponseStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + editedStyles.load( + matchedStyles: cssBaselineMatchedStyles([editedStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + + #expect(staleStyles.sections.first?.style.properties.first?.isModifiedByInspector == false) + #expect(editedStyles.sections.first?.style.properties.first?.isModifiedByInspector == true) +} + +@MainActor +@Test +func inspectorBaselinesDoNotAliasTargetScopedStyleIDs() throws { + let context = WebInspectorModelContext.preview() + let firstStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("first-target-node")), + modelContext: context + ) + let secondStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("second-target-node")), + modelContext: context + ) + let firstInitial = cssBaselineTestStyle( + id: CSS.Style.ID("shared-style", scopedToTargetRawValue: "frame-a"), + properties: [("color", "red")] + ) + let secondInitial = cssBaselineTestStyle( + id: CSS.Style.ID("shared-style", scopedToTargetRawValue: "frame-b"), + properties: [("color", "red")] + ) + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstInitial]), + inlineStyles: .init(), + computedProperties: [] + ) + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondInitial]), + inlineStyles: .init(), + computedProperties: [] + ) + + 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) + + let secondColor = try #require(secondStyles.sections.first?.style.properties.first) + #expect(firstColor.isModifiedByInspector) + #expect(secondColor.isModifiedByInspector == false) +} + +@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 secondStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("second-frame-node")), + modelContext: context + ) + let firstInitial = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: firstTargetID.rawValue), + properties: [("color", "red")] + ) + let secondInitial = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: secondTargetID.rawValue), + properties: [("color", "red")] + ) + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstInitial]), + inlineStyles: .init(), + computedProperties: [] + ) + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondInitial]), + inlineStyles: .init(), + computedProperties: [] + ) + 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")] + ) + let secondEdited = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: secondTargetID.rawValue), + properties: [("color", "blue")] + ) + firstStyles.applySetStyleText(result: firstEdited, for: firstColor.id) + secondStyles.applySetStyleText(result: secondEdited, for: secondColor.id) + + context.cssInspectorBaselineStore.reset(targetID: firstTargetID) + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstEdited]), + inlineStyles: .init(), + computedProperties: [] + ) + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondEdited]), + inlineStyles: .init(), + computedProperties: [] + ) + + #expect(firstStyles.sections.first?.style.properties.first?.isModifiedByInspector == false) + #expect(secondStyles.sections.first?.style.properties.first?.isModifiedByInspector == true) +} + private struct AttachedModelFixture { let runtime: DataKitTestRuntime let target: WebInspectorTarget @@ -826,11 +1203,19 @@ private func cssBaselineTestStyle( id: String, properties: [(name: String, value: String)] ) -> CSS.Style { - CSS.Style( - id: CSS.Style.ID(id), + cssBaselineTestStyle(id: CSS.Style.ID(id), properties: properties) +} + +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("\(id)\u{1F}\(index)"), + id: CSS.Property.ID("\(rawID)\u{1F}\(index)"), name: property.name, value: property.value, text: "\(property.name): \(property.value);", diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index f1b0dad9..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( From 8d2cd5df160c0363bc9582b045a8295a5ad7bf5c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:56:18 +0900 Subject: [PATCH 80/81] fix(testing): preserve replies completed before owner close --- .../Transport/TransportReceiver.swift | 46 +++++++++++-------- .../WebInspectorTestPeer.swift | 41 +++++++++++++++-- .../WebInspectorTestPeerTests.swift | 22 +++++++++ 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 80145ce3..f675cf9a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -68,30 +68,34 @@ 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?) } - precondition($0.tailOrdinal < UInt64.max, "TransportReceiver exhausted its message ordinal space.") - $0.tailOrdinal += 1 - $0.messages.append(QueuedMessage(ordinal: $0.tailOrdinal, payload: message)) - guard $0.core.value != 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 - } - Task { - await drain(generation: drainGeneration) + if let drainGeneration = admission.drainGeneration { + Task { + await drain(generation: drainGeneration) + } } + return admission.ordinal } /// Returns the ordinal of the newest message accepted by this receiver. @@ -102,6 +106,12 @@ package final class TransportReceiver: Sendable { 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 { diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift index c3ee61f8..e1273332 100644 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift @@ -187,6 +187,7 @@ public actor WebInspectorTestPeer { private var state: State private var nextCommandOrdinal: UInt64 private var outstandingCommands: [UInt64: ReplyRoute] + private var postDrainActionForTesting: (@Sendable () async -> Void)? init() { let commandMailbox = WebInspectorTestCommandMailbox() @@ -198,6 +199,7 @@ public actor WebInspectorTestPeer { state = .unattached nextCommandOrdinal = 0 outstandingCommands = [:] + postDrainActionForTesting = nil } isolated deinit { @@ -358,6 +360,12 @@ public actor WebInspectorTestPeer { 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 { @@ -392,20 +400,43 @@ public actor WebInspectorTestPeer { 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 { - receiver.receive(message) + 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.") } - let through = receiver.tailOrdinal() 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 } - guard case .open = state, core === admittedCore else { - throw WebInspectorTestPeerError.connectionClosed - } } private func requireCoreAdmission() async throws -> ConnectionCore { diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift index f960c0ef..6f2a5be0 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift @@ -106,6 +106,28 @@ func commandAndEventAdmissionRejectCoreCloseBeforePeerDetach() async throws { } } +@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() From defd8dd5dad77d402e453923b0f48a97ea8cd262 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:56:24 +0900 Subject: [PATCH 81/81] fix(lifecycle): close stale attachment cleanup gaps --- .../WebInspectorModelContext.swift | 90 +++++++++++++++---- .../Transport/TransportSession.swift | 25 ++++-- .../Containers/WebInspectorSession.swift | 13 ++- .../WebInspectorDataKitTests.swift | 48 ++++++++++ .../ParentContainerTests.swift | 46 ++++++++++ 5 files changed, 194 insertions(+), 28 deletions(-) diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift index fbb69872..14ce5aaa 100644 --- a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -304,15 +304,19 @@ public final class WebInspectorModelContext { case none case reset( network: NetworkRequestStore.IndexWork, - console: ConsoleMessageStore.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? + console: ConsoleMessageStore.IndexAcknowledgementWork?, + pageHighlightDOM: DOM? ) case inspectorSelection( dom: DOM, @@ -325,14 +329,25 @@ public final class WebInspectorModelContext { switch self { case .none: return ReducerWorkResult(commit: commit, output: .none) - case let .reset(network, console): + case let .reset(network, console, pageHighlightDOM): + let networkResult = await network.run() + let consoleResult = await console.run() return ReducerWorkResult( commit: commit, output: .reset( - network: await network.run(), - console: await console.run() + 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, @@ -349,9 +364,10 @@ public final class WebInspectorModelContext { case let .consoleAcknowledgement(work): await work.run() return ReducerWorkResult(commit: commit, output: .none) - case let .acknowledgements(network, console): + 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, @@ -414,6 +430,19 @@ public final class WebInspectorModelContext { ) } } + + 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 { @@ -425,8 +454,10 @@ public final class WebInspectorModelContext { case none case reset( network: NetworkRequestStore.IndexResult, - console: ConsoleMessageStore.IndexResult + console: ConsoleMessageStore.IndexResult, + pageHighlightDOM: DOM? ) + case pageHighlightClear(DOM) case network(NetworkRequestStore.IndexResult) case console(ConsoleMessageStore.IndexResult) case inspectorSelection(InspectorSelectionOutcome) @@ -635,6 +666,16 @@ public final class WebInspectorModelContext { 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) } @@ -1054,11 +1095,14 @@ public final class WebInspectorModelContext { switch result.output { case .none: followup = nil - case let .reset(networkResult, consoleResult): + case let .reset(networkResult, consoleResult, pageHighlightDOM): followup = .acknowledgements( network: networkRequests.commit(networkResult), - console: consoleMessages.commit(consoleResult) + console: consoleMessages.commit(consoleResult), + pageHighlightDOM: pageHighlightDOM ) + case let .pageHighlightClear(dom): + followup = .pageHighlightClear(dom) case let .network(networkResult): followup = networkRequests.commit( networkResult @@ -1205,12 +1249,13 @@ public final class WebInspectorModelContext { return nil } switch result.output { - case let .reset(networkResult, consoleResult): + case let .reset(networkResult, consoleResult, pageHighlightDOM): return .acknowledgements( network: networkRequests.commit(networkResult), - console: consoleMessages.commit(consoleResult) + console: consoleMessages.commit(consoleResult), + pageHighlightDOM: pageHighlightDOM ) - case .none, .network, .console, .inspectorSelection: + case .none, .network, .console, .pageHighlightClear, .inspectorSelection: preconditionFailure("A detached reset produced an invalid reducer result.") } } @@ -1250,7 +1295,7 @@ public final class WebInspectorModelContext { private func prepareSemanticReset( ) -> ReducerWork { domState.advanceDocumentEpoch() - resetDOM() + let pageHighlightDOM = resetDOM() runtimeState.reset() let consoleReset = consoleMessages.prepareClearForLifecycle( modelContext: self @@ -1259,7 +1304,8 @@ public final class WebInspectorModelContext { let networkReset = networkRequests.prepareResetForNewAttachment() return .reset( network: networkRequests.indexWork(for: networkReset), - console: consoleMessages.indexWork(for: consoleReset.queryIndexReset) + console: consoleMessages.indexWork(for: consoleReset.queryIndexReset), + pageHighlightDOM: pageHighlightDOM ) } @@ -1390,9 +1436,14 @@ public final class WebInspectorModelContext { } binding.domAuthority[target.id] = .awaiting(documentEpoch) self.binding = binding + let work: ReducerWork if target.id == binding.currentPageID { domState.advanceDocumentEpoch() - resetDOM() + if let pageHighlightDOM = resetDOM() { + work = .preparePageHighlightClear(pageHighlightDOM) + } else { + work = .none + } } else if let frameID = target.frameID { cssInspectorBaselineStore.reset(targetID: target.id) applyDOMStateEffects( @@ -1400,13 +1451,14 @@ public final class WebInspectorModelContext { forFrameID: frameID ) ) + work = .none } else { return prepareProtocolFailure( "A non-page DOM target had no frame identity.", token: token ) } - return PreparedReducerStep(work: .none, commit: .record(token: token)) + return PreparedReducerStep(work: work, commit: .record(token: token)) case let .event(generation, sequence, payload): guard var binding = bindingForSequencedRecord( @@ -3008,10 +3060,14 @@ extension WebInspectorModelContext { ) } - private func resetDOM() { + private func resetDOM() -> DOM? { cssInspectorBaselineStore.reset() let effects = domState.resetDocument() + let pageHighlightDOM = effects.shouldClearPageHighlight + ? currentPage?.dom + : nil applyDOMStateEffects(effects) + return pageHighlightDOM } } extension WebInspectorModelContext { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 5436c0de..384cfd8a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1234,7 +1234,10 @@ package actor ConnectionCore { let isCurrentPageTarget = targetRegistry.isCurrentPageModelTarget(target) let admission = ConnectionDirectCommandAdmission( bindingGeneration: isCurrentPageTarget ? currentPageGeneration : nil, - documentEpoch: isCurrentPageTarget && isDocumentSensitive(command.domain) + documentEpoch: isCurrentPageTarget && isDocumentSensitive( + command.domain, + method: command.method + ) ? modelDocumentEpoch(for: targetID) : nil ) @@ -1387,7 +1390,7 @@ package actor ConnectionCore { !registration.configuredDomains.contains(requiredDomain) { throw ConnectionModelCommandError.domainNotConfigured(proxyDomain) } - if isDocumentSensitive(domain), authorization.document == nil { + if isDocumentSensitive(domain, method: method), authorization.document == nil { throw ConnectionModelCommandError.documentAuthorizationRequired(proxyDomain) } } @@ -1431,7 +1434,7 @@ package actor ConnectionCore { targetID = candidate } - if isDocumentSensitive(domain) { + if isDocumentSensitive(domain, method: method) { guard let targetID, let document = authorization.document, ProtocolTarget.ID(document.targetID.rawValue) == targetID, @@ -1447,7 +1450,7 @@ package actor ConnectionCore { return .waiting } - if isDocumentSensitive(domain) { + if isDocumentSensitive(domain, method: method) { guard let targetID, let document = authorization.document, let bootstrap = registration.domBootstrap, @@ -1636,8 +1639,14 @@ package actor ConnectionCore { } } - private func isDocumentSensitive(_ domain: ProtocolDomain) -> Bool { - domain == .dom || domain == .css || domain == .inspector + 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 { @@ -2503,7 +2512,7 @@ package actor ConnectionCore { var effects = invalidateModelCommands( where: { task in guard task.authorization.generation == currentPageGeneration, - isDocumentSensitive(task.domain), + isDocumentSensitive(task.domain, method: task.method), let document = task.authorization.document else { return false } @@ -2515,7 +2524,7 @@ package actor ConnectionCore { ) let bindingReplies = replyStore.removePendingReplies { pending in guard pending.targetID == targetID, - isDocumentSensitive(pending.domain) else { + isDocumentSensitive(pending.domain, method: pending.method) else { return false } let bindingGeneration: WebInspectorPage.Generation? diff --git a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift index 974d04af..c69ad11a 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -98,11 +98,13 @@ public final class WebInspectorSession { 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( makeProxy: makeProxy, - makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver + makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver, + afterModelAttach: afterModelAttach ) } @@ -110,7 +112,8 @@ public final class WebInspectorSession { 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() @@ -131,7 +134,11 @@ public final class WebInspectorSession { await proxy.close() throw error } + if let afterModelAttach { + await afterModelAttach() + } guard isCurrentAttachmentGeneration(generation) else { + await model.detachIfAttached(to: proxy) throw CancellationError() } startPageUserInterfaceStyleObservation(makePageUserInterfaceStyleObserver) diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index 56b4feac..c0845457 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -429,6 +429,54 @@ func explicitDOMSelectionSupersedesPendingPickerResolution() async throws { } } +@MainActor +@Test +func documentResetHidesRecordedPageHighlight() async throws { + let highlightedID = DOM.Node.ID("highlighted-node") + let document = DOM.Node( + id: DOM.Node.ID("highlight-document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: highlightedID, + nodeType: 1, + nodeName: "MAIN", + localName: "main" + ), + ] + ) + try await withAttachedModelContext( + configuration: .init(domains: [.dom]), + document: document + ) { fixture in + let highlightedNode = try #require( + try fixture.context.domNode(id: DOMNode.ID(highlightedID)) + ) + await fixture.runtime.wire.respond(to: "DOM.highlightNode") + try await fixture.context.highlightDOMNode(highlightedNode) + + await fixture.runtime.wire.respond(to: "DOM.hideHighlight") + await fixture.runtime.wire.respond( + to: "DOM.getDocument", + with: try domDocumentResult(DOM.Node( + id: DOM.Node.ID("replacement-document"), + nodeType: 9, + nodeName: "#document" + )) + ) + try await fixture.runtime.wire.emitRaw(.documentUpdated, target: fixture.target) + + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "DOM.hideHighlight", + count: 1 + ) + #expect(fixture.runtime.wire.observations.commands.filter { + $0.method == "DOM.hideHighlight" + }.count == 1) + } +} + @MainActor @Test func runtimeObjectGroupsUseUniqueWireNamesAndReleaseExactlyOnce() async throws { diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index 5db595b0..fb7bfe2f 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -183,6 +183,52 @@ struct ParentContainerTests { #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)