diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28f8260718a8..344d0ea2cbfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -631,12 +631,12 @@ jobs: -only-testing:cmuxTests/CLISSHSessionAttachAnchorTests \ test - - name: Run omnibar suggestion click regression + - name: Run focused browser regressions if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | - # Focused gate for https://github.com/manaflow-ai/cmux/issues/7380. + # Focused gates for https://github.com/manaflow-ai/cmux/issues/7380 and #8054. # The tolerant full-suite step can crash-skip late suites (see #5888 - # note above), so this regression gets a non-tolerant focused + # note above), so these regressions get a non-tolerant focused # invocation. set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" @@ -649,6 +649,7 @@ jobs: -destination "platform=macOS" \ CMUX_SKIP_ZIG_BUILD=1 \ -only-testing:cmuxTests/BrowserOmnibarSuggestionClickRoutingTests \ + -only-testing:cmuxTests/BrowserPanelViewIdentityTests \ test - name: Run remote tmux mirror active-pane regression diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a19a0aa8873f..35bf1f53cb6e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -13070,6 +13070,14 @@ struct CMUXCLI { let subArgs = Array(args.dropFirst()) let browserValueTextFormatter = BrowserValueTextFormatter() + // A post-action snapshot can spend 3s in document readiness, 10s in the + // requested action, 10s in snapshot JavaScript, and 2.5s in recovery. + // Keep transport headroom beyond that 25.5s app-side maximum. + func sendBrowserAutomationRequest(method: String, params: [String: Any]) throws -> [String: Any] { + let responseTimeout: TimeInterval = (params["snapshot_after"] as? Bool) == true ? 30 : 20 + return try client.sendV2(method: method, params: params, responseTimeout: responseTimeout) + } + func requireSurface() throws -> String { guard let raw = surfaceRaw else { throw CLIError(message: "browser \(subcommand) requires a surface handle (use: browser \(subcommand) ... or --surface)") @@ -13538,7 +13546,7 @@ struct CMUXCLI { if snapshotAfter { params["snapshot_after"] = true } - let payload = try client.sendV2(method: "browser.navigate", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.navigate", params: params) output(payload, fallback: "OK") return } @@ -13554,7 +13562,7 @@ struct CMUXCLI { if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } - let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) + let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } @@ -13754,7 +13762,7 @@ struct CMUXCLI { params["max_depth"] = depth } - let payload = try client.sendV2(method: "browser.snapshot", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.snapshot", params: params) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { @@ -13770,7 +13778,7 @@ struct CMUXCLI { guard !trimmed.isEmpty else { throw CLIError(message: "browser eval requires a script") } - let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) + let payload = try sendBrowserAutomationRequest(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) let fallback: String if let value = payload["value"] { fallback = browserValueTextFormatter.string(from: value) @@ -13824,7 +13832,7 @@ struct CMUXCLI { // Give the socket response timeout headroom beyond the wait deadline so long // waits are reported by the app handler instead of dying at the socket layer. let waitMs = (params["timeout_ms"] as? Int) ?? 5_000 - let responseTimeout = max(10.0, Double(waitMs) / 1000.0 + 5.0) + let responseTimeout = max(15.0, Double(waitMs) / 1000.0 + 8.0) let payload = try client.sendV2(method: "browser.wait", params: params, responseTimeout: responseTimeout) output(payload, fallback: "OK") return @@ -13852,7 +13860,7 @@ struct CMUXCLI { if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } - let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) + let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } @@ -13885,7 +13893,7 @@ struct CMUXCLI { if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } - let payload = try client.sendV2(method: method, params: params) + let payload = try sendBrowserAutomationRequest(method: method, params: params) output(payload, fallback: "OK") return } @@ -13907,7 +13915,7 @@ struct CMUXCLI { if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } - let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) + let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } @@ -13928,7 +13936,7 @@ struct CMUXCLI { if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } - let payload = try client.sendV2(method: "browser.select", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.select", params: params) output(payload, fallback: "OK") return } @@ -13962,7 +13970,7 @@ struct CMUXCLI { params["snapshot_after"] = true } - let payload = try client.sendV2(method: "browser.scroll", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.scroll", params: params) output(payload, fallback: "OK") return } @@ -13972,7 +13980,12 @@ struct CMUXCLI { let (outPathOpt, _) = parseOption(subArgs, name: "--out") let localJSONOutput = hasFlag(subArgs, name: "--json") let outputAsJSON = effectiveJSONOutput || localJSONOutput - var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) + // Leave room beyond the app's capture deadline for its liveness probe and recovery reply. + var payload = try client.sendV2( + method: "browser.screenshot", + params: ["surface_id": sid], + responseTimeout: 25 + ) func fileURL(fromPath rawPath: String) -> URL { let resolvedPath = resolvePath(rawPath) @@ -14159,7 +14172,7 @@ struct CMUXCLI { "box": "browser.get.box", "styles": "browser.get.styles", ] - let payload = try client.sendV2(method: methodMap[getVerb]!, params: params) + let payload = try sendBrowserAutomationRequest(method: methodMap[getVerb]!, params: params) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { @@ -14199,7 +14212,7 @@ struct CMUXCLI { guard let method = methodMap[isVerb] else { throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)") } - let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector]) + let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid, "selector": selector]) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { @@ -14282,7 +14295,7 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser find locator: \(locator)") } - let payload = try client.sendV2(method: method, params: params) + let payload = try sendBrowserAutomationRequest(method: method, params: params) output(payload, fallback: "OK") return } @@ -14302,7 +14315,7 @@ struct CMUXCLI { guard let selector else { throw CLIError(message: "browser frame requires a selector or 'main'") } - let payload = try client.sendV2(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector]) + let payload = try sendBrowserAutomationRequest(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector]) output(payload, fallback: "OK") return } @@ -14320,10 +14333,10 @@ struct CMUXCLI { if !text.isEmpty { params["text"] = text } - let payload = try client.sendV2(method: "browser.dialog.accept", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.dialog.accept", params: params) output(payload, fallback: "OK") case "dismiss": - let payload = try client.sendV2(method: "browser.dialog.dismiss", params: ["surface_id": sid]) + let payload = try sendBrowserAutomationRequest(method: "browser.dialog.dismiss", params: ["surface_id": sid]) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser dialog subcommand: \(dialogVerb)") @@ -14445,7 +14458,7 @@ struct CMUXCLI { if let key = positional.first { params["key"] = key } - let payload = try client.sendV2(method: "browser.storage.get", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.storage.get", params: params) output(payload, fallback: "OK") case "set": guard positional.count >= 2 else { @@ -14453,10 +14466,10 @@ struct CMUXCLI { } params["key"] = positional[0] params["value"] = positional[1] - let payload = try client.sendV2(method: "browser.storage.set", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.storage.set", params: params) output(payload, fallback: "OK") case "clear": - let payload = try client.sendV2(method: "browser.storage.clear", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.storage.clear", params: params) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser storage subcommand: \(op)") @@ -14518,7 +14531,7 @@ struct CMUXCLI { if consoleVerb != "list" && consoleVerb != "clear" { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } - let payload = try client.sendV2(method: method, params: ["surface_id": sid]) + let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid]) if effectiveJSONOutput || consoleVerb == "clear" { output(payload, fallback: "OK") } else { @@ -14536,7 +14549,7 @@ struct CMUXCLI { } else if errorsVerb != "list" { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } - let payload = try client.sendV2(method: "browser.errors.list", params: params) + let payload = try sendBrowserAutomationRequest(method: "browser.errors.list", params: params) if effectiveJSONOutput || errorsVerb == "clear" { output(payload, fallback: "OK") } else { @@ -14552,7 +14565,7 @@ struct CMUXCLI { guard let selector else { throw CLIError(message: "browser highlight requires a selector") } - let payload = try client.sendV2(method: "browser.highlight", params: ["surface_id": sid, "selector": selector]) + let payload = try sendBrowserAutomationRequest(method: "browser.highlight", params: ["surface_id": sid, "selector": selector]) output(payload, fallback: "OK") return } @@ -14575,7 +14588,7 @@ struct CMUXCLI { default: throw CLIError(message: "Unsupported browser state subcommand: \(stateVerb)") } - let payload = try client.sendV2(method: method, params: ["surface_id": sid, "path": path]) + let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid, "path": path]) output(payload, fallback: "OK") return } @@ -14589,7 +14602,7 @@ struct CMUXCLI { guard !content.isEmpty else { throw CLIError(message: "browser \(subcommand) requires content") } - let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content]) + let payload = try sendBrowserAutomationRequest(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content]) output(payload, fallback: "OK") return } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadiness.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadiness.swift new file mode 100644 index 000000000000..96960cc1dffa --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadiness.swift @@ -0,0 +1,89 @@ +public import Foundation + +/// Tracks the first committed document for the browser instance owned by one panel. +/// +/// The owner calls ``bind(to:hasCommittedDocument:)`` whenever it replaces its WebView and +/// ``didCommit(instanceID:)`` from the corresponding navigation-delegate callback. Automation +/// callers can then await the real lifecycle signal without polling WebKit state. +@MainActor +public final class BrowserAutomationDocumentReadiness { + private var observedInstanceID: UUID? + private var committedInstanceID: UUID? + private var waiters: [UUID: AsyncStream.Continuation] = [:] + + /// Creates an empty document-readiness owner. + public init() {} + + /// Starts observing a browser instance and supersedes waits for any previous instance. + /// - Parameters: + /// - instanceID: Stable identity of the newly bound browser instance. + /// - hasCommittedDocument: Whether the instance committed before the owner attached its delegate. + public func bind(to instanceID: UUID, hasCommittedDocument: Bool) { + if observedInstanceID == instanceID { + if hasCommittedDocument, committedInstanceID != instanceID { + committedInstanceID = instanceID + finishWaiters(with: .committed) + } + return + } + + finishWaiters(with: .superseded) + observedInstanceID = instanceID + committedInstanceID = hasCommittedDocument ? instanceID : nil + } + + /// Records a navigation commit when it belongs to the currently bound browser instance. + /// - Parameter instanceID: Identity captured when the navigation delegate was bound. + public func didCommit(instanceID: UUID) { + guard observedInstanceID == instanceID else { return } + committedInstanceID = instanceID + finishWaiters(with: .committed) + } + + /// Stops observing the current browser instance and cancels its pending waits. + public func invalidate() { + observedInstanceID = nil + committedInstanceID = nil + finishWaiters(with: .cancelled) + } + + /// Returns whether the currently bound browser instance has committed a document. + /// - Parameter instanceID: Browser instance to inspect. + /// - Returns: `true` only after that exact instance produced a commit signal. + public func hasCommittedDocument(for instanceID: UUID) -> Bool { + observedInstanceID == instanceID && committedInstanceID == instanceID + } + + /// Waits for a real commit signal from the specified browser instance. + /// - Parameter instanceID: Browser instance whose first document is required. + /// - Returns: Whether the instance committed, was superseded, or the wait was cancelled. + public func waitForCommit( + instanceID: UUID + ) async -> BrowserAutomationDocumentReadinessOutcome { + guard !Task.isCancelled else { return .cancelled } + guard observedInstanceID == instanceID else { return .superseded } + guard committedInstanceID != instanceID else { return .committed } + + let waiterID = UUID() + let (events, continuation) = AsyncStream.makeStream( + of: BrowserAutomationDocumentReadinessOutcome.self, + bufferingPolicy: .bufferingNewest(1) + ) + waiters[waiterID] = continuation + defer { + waiters.removeValue(forKey: waiterID) + continuation.finish() + } + var iterator = events.makeAsyncIterator() + return await iterator.next() ?? .cancelled + } + + private func finishWaiters(with outcome: BrowserAutomationDocumentReadinessOutcome) { + let pendingWaiters = Array(waiters.values) + waiters.removeAll() + for waiter in pendingWaiters { + waiter.yield(outcome) + waiter.finish() + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadinessOutcome.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadinessOutcome.swift new file mode 100644 index 000000000000..e197366c3cd1 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationDocumentReadinessOutcome.swift @@ -0,0 +1,11 @@ +/// Result of waiting for a browser instance to commit its first automation document. +public enum BrowserAutomationDocumentReadinessOutcome: Sendable, Equatable { + /// The observed browser instance committed a document and is ready for JavaScript automation. + case committed + + /// A newer browser instance replaced the observed instance before it committed a document. + case superseded + + /// The caller cancelled its wait before the observed instance committed a document. + case cancelled +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationProbeSignal.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationProbeSignal.swift new file mode 100644 index 000000000000..973d8bd6d745 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationProbeSignal.swift @@ -0,0 +1,6 @@ +/// Internal result from racing browser automation liveness callbacks against their deadline. +enum BrowserAutomationProbeSignal: Sendable { + case responsive + case timedOut + case cancelled +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationRecoveryOutcome.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationRecoveryOutcome.swift new file mode 100644 index 000000000000..ed5faa2caec9 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationRecoveryOutcome.swift @@ -0,0 +1,14 @@ +/// Result of checking whether a browser automation callback pipeline is alive. +public enum BrowserAutomationRecoveryOutcome: Sendable, Equatable { + /// The liveness callback arrived before the deadline, so the current WebView remains authoritative. + case responsive + + /// The callback missed its deadline and the owning browser surface replaced the unresponsive WebView. + case recovered + + /// The callback missed its deadline, but another lifecycle path had already replaced the observed WebView. + case superseded + + /// The check was cancelled before liveness or timeout produced an outcome. + case cancelled +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationWatchdog.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationWatchdog.swift new file mode 100644 index 000000000000..8f92ef5e4633 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Control/BrowserAutomationWatchdog.swift @@ -0,0 +1,176 @@ +public import Foundation + +/// Applies one bounded liveness deadline before asking a browser surface to replace an unresponsive WebView. +/// +/// The watchdog owns no WebKit state. Its caller supplies the callback-based liveness probe and the +/// synchronous recovery mutation, keeping the package testable without launching AppKit or WebKit. +/// Every supplied probe must complete before the pipeline is considered responsive; one missing callback +/// reaches the injected deadline even when another WebKit callback channel remains alive. +@MainActor +public final class BrowserAutomationWatchdog { + /// Starts a liveness probe and invokes its completion when the browser automation pipeline responds. + public typealias Probe = @MainActor ( + _ completion: @escaping @MainActor @Sendable () -> Void + ) -> Void + + /// Replaces the observed WebView, returning `false` when another lifecycle path already superseded it. + public typealias Recovery = @MainActor () -> Bool + + /// Cancellable timing source used for the liveness deadline. + public typealias Sleep = @Sendable (_ duration: Duration) async throws -> Void + + private let probeTimeout: Duration + private let sleep: Sleep + private var inFlightObservedInstanceID: UUID? + private var inFlightWaiters: [ + UUID: AsyncStream.Continuation + ] = [:] + + /// Creates a browser automation watchdog using a continuous-clock deadline. + /// - Parameter probeTimeout: Maximum time to wait for a liveness callback before recovery. + public init(probeTimeout: Duration = .seconds(1)) { + self.probeTimeout = probeTimeout + let clock = ContinuousClock() + self.sleep = { duration in + try await clock.sleep(for: duration) + } + } + + /// Creates a browser automation watchdog with an injected timing source. + /// - Parameters: + /// - probeTimeout: Maximum time to wait for a liveness callback before recovery. + /// - sleep: Cancellable timing source. Tests can inject an immediate or controlled deadline. + public init( + probeTimeout: Duration = .seconds(1), + sleep: @escaping Sleep + ) { + self.probeTimeout = probeTimeout + self.sleep = sleep + } + + /// Probes every relevant browser automation callback channel and recovers when any callback misses its deadline. + /// + /// Concurrent checks for the same browser instance join the first check and receive its outcome. A check for + /// a newer instance supersedes callers waiting on the old instance without starting duplicate recovery work. + /// - Parameters: + /// - observedInstanceID: Stable identity of the browser instance whose failed operation triggered the check. + /// - probes: Cheap, side-effect-free liveness operations. An empty collection is treated as responsive. + /// - recover: Replaces the WebView if it is still the instance observed by the failed operation. + /// - Returns: The liveness or recovery outcome. + public func recoverIfUnresponsive( + observedInstanceID: UUID, + probes: [Probe], + recover: Recovery + ) async -> BrowserAutomationRecoveryOutcome { + guard !Task.isCancelled else { return .cancelled } + guard !probes.isEmpty else { return .responsive } + + if inFlightObservedInstanceID == observedInstanceID { + return await waitForInFlightRecovery(observedInstanceID: observedInstanceID) + } + + if inFlightObservedInstanceID != nil { + finishInFlightRecovery(with: .superseded) + } + + inFlightObservedInstanceID = observedInstanceID + let signal = await performLivenessCheck(probes: probes) + guard inFlightObservedInstanceID == observedInstanceID else { return .superseded } + + let outcome: BrowserAutomationRecoveryOutcome + switch signal { + case .responsive: + outcome = .responsive + case .timedOut: + outcome = recover() ? .recovered : .superseded + case .cancelled: + outcome = .cancelled + } + finishInFlightRecovery(with: outcome) + return outcome + } + + /// Invalidates the current check and cancels callers waiting on its shared result. + public func invalidate() { + guard inFlightObservedInstanceID != nil else { return } + finishInFlightRecovery(with: .cancelled) + } + + private func performLivenessCheck( + probes: [Probe] + ) async -> BrowserAutomationProbeSignal { + let (signals, continuation) = AsyncStream.makeStream( + of: Int.self, + bufferingPolicy: .bufferingOldest(probes.count) + ) + for (index, probe) in probes.enumerated() { + probe { + continuation.yield(index) + } + } + + let expectedProbeCount = probes.count + let signal = await withTaskGroup( + of: BrowserAutomationProbeSignal.self, + returning: BrowserAutomationProbeSignal.self + ) { group in + group.addTask { + var iterator = signals.makeAsyncIterator() + var completedProbeIndexes = Set() + while let index = await iterator.next() { + completedProbeIndexes.insert(index) + if completedProbeIndexes.count == expectedProbeCount { + return .responsive + } + } + return .cancelled + } + group.addTask { [probeTimeout, sleep] in + do { + try await sleep(probeTimeout) + } catch { + return .cancelled + } + return Task.isCancelled ? .cancelled : .timedOut + } + + let first = await group.next() ?? .cancelled + group.cancelAll() + continuation.finish() + await group.waitForAll() + return first + } + + return Task.isCancelled ? .cancelled : signal + } + + private func waitForInFlightRecovery( + observedInstanceID: UUID + ) async -> BrowserAutomationRecoveryOutcome { + guard !Task.isCancelled else { return .cancelled } + guard inFlightObservedInstanceID == observedInstanceID else { return .superseded } + + let waiterID = UUID() + let (events, continuation) = AsyncStream.makeStream( + of: BrowserAutomationRecoveryOutcome.self, + bufferingPolicy: .bufferingNewest(1) + ) + inFlightWaiters[waiterID] = continuation + defer { + inFlightWaiters.removeValue(forKey: waiterID) + continuation.finish() + } + var iterator = events.makeAsyncIterator() + return await iterator.next() ?? .cancelled + } + + private func finishInFlightRecovery(with outcome: BrowserAutomationRecoveryOutcome) { + let waiters = Array(inFlightWaiters.values) + inFlightWaiters.removeAll() + inFlightObservedInstanceID = nil + for waiter in waiters { + waiter.yield(outcome) + waiter.finish() + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationDocumentReadinessTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationDocumentReadinessTests.swift new file mode 100644 index 000000000000..2e2124ed436d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationDocumentReadinessTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +@MainActor +@Suite("Browser automation document readiness") +struct BrowserAutomationDocumentReadinessTests { + @Test("A document committed before delegate binding is immediately ready") + func precommittedInstanceIsReady() async { + let readiness = BrowserAutomationDocumentReadiness() + let instanceID = UUID() + + readiness.bind(to: instanceID, hasCommittedDocument: true) + + #expect(readiness.hasCommittedDocument(for: instanceID)) + #expect(await readiness.waitForCommit(instanceID: instanceID) == .committed) + } + + @Test("A navigation commit releases automation waiting on the same instance") + func matchingCommitReleasesWaiter() async { + let readiness = BrowserAutomationDocumentReadiness() + let instanceID = UUID() + readiness.bind(to: instanceID, hasCommittedDocument: false) + + let wait = Task { @MainActor in + await readiness.waitForCommit(instanceID: instanceID) + } + readiness.didCommit(instanceID: instanceID) + + #expect(await wait.value == .committed) + #expect(readiness.hasCommittedDocument(for: instanceID)) + } + + @Test("Replacing a browser instance supersedes its pending automation wait") + func replacementSupersedesWaiter() async { + let readiness = BrowserAutomationDocumentReadiness() + let firstInstanceID = UUID() + let secondInstanceID = UUID() + readiness.bind(to: firstInstanceID, hasCommittedDocument: false) + + let wait = Task { @MainActor in + await readiness.waitForCommit(instanceID: firstInstanceID) + } + readiness.bind(to: secondInstanceID, hasCommittedDocument: false) + + #expect(await wait.value == .superseded) + #expect(!readiness.hasCommittedDocument(for: firstInstanceID)) + #expect(!readiness.hasCommittedDocument(for: secondInstanceID)) + } + + @Test("A stale navigation commit cannot ready the replacement instance") + func staleCommitIsIgnored() { + let readiness = BrowserAutomationDocumentReadiness() + let firstInstanceID = UUID() + let secondInstanceID = UUID() + readiness.bind(to: firstInstanceID, hasCommittedDocument: false) + readiness.bind(to: secondInstanceID, hasCommittedDocument: false) + + readiness.didCommit(instanceID: firstInstanceID) + + #expect(!readiness.hasCommittedDocument(for: firstInstanceID)) + #expect(!readiness.hasCommittedDocument(for: secondInstanceID)) + } + + @Test("Cancelling a commit wait releases it without changing readiness") + func cancellationReleasesWaiter() async { + let (registrations, registrationContinuation) = AsyncStream.makeStream(of: Void.self) + var registrationIterator = registrations.makeAsyncIterator() + let readiness = BrowserAutomationDocumentReadiness() + let instanceID = UUID() + readiness.bind(to: instanceID, hasCommittedDocument: false) + + let wait = Task { @MainActor in + // This synchronous signal and same-actor call form one run-to-suspension + // region: the test cannot resume until the readiness waiter is registered. + registrationContinuation.yield() + return await readiness.waitForCommit(instanceID: instanceID) + } + let registered: Void? = await registrationIterator.next() + #expect(registered != nil) + wait.cancel() + + #expect(await wait.value == .cancelled) + #expect(!readiness.hasCommittedDocument(for: instanceID)) + registrationContinuation.finish() + } + + @Test("Invalidating document readiness cancels a pending commit wait") + func invalidationCancelsWaiter() async { + let (registrations, registrationContinuation) = AsyncStream.makeStream(of: Void.self) + var registrationIterator = registrations.makeAsyncIterator() + let readiness = BrowserAutomationDocumentReadiness() + let instanceID = UUID() + readiness.bind(to: instanceID, hasCommittedDocument: false) + + let wait = Task { @MainActor in + registrationContinuation.yield() + return await readiness.waitForCommit(instanceID: instanceID) + } + let registered: Void? = await registrationIterator.next() + #expect(registered != nil) + readiness.invalidate() + + #expect(await wait.value == .cancelled) + #expect(!readiness.hasCommittedDocument(for: instanceID)) + #expect(await readiness.waitForCommit(instanceID: instanceID) == .superseded) + registrationContinuation.finish() + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationWatchdogTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationWatchdogTests.swift new file mode 100644 index 000000000000..c13b8ccd2b0d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Control/BrowserAutomationWatchdogTests.swift @@ -0,0 +1,394 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +@MainActor +@Suite("Browser automation watchdog") +struct BrowserAutomationWatchdogTests { + @Test("A completed liveness probe preserves the current browser process") + func responsiveProbeDoesNotRecover() async { + var recoveryCount = 0 + let watchdog = BrowserAutomationWatchdog( + sleep: { duration in + try await ContinuousClock().sleep(for: duration) + } + ) + + let outcome = await watchdog.recoverIfUnresponsive( + observedInstanceID: UUID(), + probes: [{ finish in finish() }], + recover: { + recoveryCount += 1 + return true + } + ) + + #expect(outcome == .responsive) + #expect(recoveryCount == 0) + } + + @Test("A missing liveness callback replaces the unresponsive browser process") + func timedOutProbeRecovers() async { + var recoveryCount = 0 + let watchdog = BrowserAutomationWatchdog(sleep: { _ in }) + + let outcome = await watchdog.recoverIfUnresponsive( + observedInstanceID: UUID(), + probes: [{ _ in }], + recover: { + recoveryCount += 1 + return true + } + ) + + #expect(outcome == .recovered) + #expect(recoveryCount == 1) + } + + @Test("A WebView replaced during the probe is not replaced a second time") + func supersededProbeDoesNotRecoverAgain() async { + var recoveryCount = 0 + let watchdog = BrowserAutomationWatchdog(sleep: { _ in }) + + let outcome = await watchdog.recoverIfUnresponsive( + observedInstanceID: UUID(), + probes: [{ _ in }], + recover: { + recoveryCount += 1 + return false + } + ) + + #expect(outcome == .superseded) + #expect(recoveryCount == 1) + } + + @Test("A responsive snapshot cannot mask a missing JavaScript callback") + func oneResponsiveChannelStillRecovers() async { + var recoveryCount = 0 + let watchdog = BrowserAutomationWatchdog(sleep: { _ in }) + + let outcome = await watchdog.recoverIfUnresponsive( + observedInstanceID: UUID(), + probes: [ + { _ in }, + { finish in finish() }, + ], + recover: { + recoveryCount += 1 + return true + } + ) + + #expect(outcome == .recovered) + #expect(recoveryCount == 1) + } + + @Test("All browser callback channels must respond before the pipeline is healthy") + func allResponsiveChannelsPreserveBrowserProcess() async { + var recoveryCount = 0 + let watchdog = BrowserAutomationWatchdog() + + let outcome = await watchdog.recoverIfUnresponsive( + observedInstanceID: UUID(), + probes: [ + { finish in finish() }, + { finish in finish() }, + ], + recover: { + recoveryCount += 1 + return true + } + ) + + #expect(outcome == .responsive) + #expect(recoveryCount == 0) + } + + @Test("Concurrent checks for one browser instance share one liveness operation") + func concurrentChecksShareOneRecovery() async { + var probeCount = 0 + var recoveryCount = 0 + var pendingProbeCompletions: [@MainActor @Sendable () -> Void] = [] + let (probeStarts, probeStartsContinuation) = AsyncStream.makeStream( + of: Void.self, + bufferingPolicy: .bufferingOldest(2) + ) + var probeStartsIterator = probeStarts.makeAsyncIterator() + let (followerJoins, followerJoinsContinuation) = AsyncStream.makeStream(of: Void.self) + var followerJoinsIterator = followerJoins.makeAsyncIterator() + let watchdog = BrowserAutomationWatchdog() + let observedInstanceID = UUID() + let probe: BrowserAutomationWatchdog.Probe = { finish in + probeCount += 1 + pendingProbeCompletions.append(finish) + probeStartsContinuation.yield() + } + let recover: BrowserAutomationWatchdog.Recovery = { + recoveryCount += 1 + return true + } + + let firstCheck = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: recover + ) + } + let firstProbeStarted: Void? = await probeStartsIterator.next() + #expect(firstProbeStarted != nil) + + let secondCheck = Task { @MainActor in + // This synchronous signal and the same-actor call form one run-to-suspension region: + // the test cannot resume on MainActor until recovery appends this follower and awaits. + followerJoinsContinuation.yield() + return await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: recover + ) + } + let followerJoined: Void? = await followerJoinsIterator.next() + #expect(followerJoined != nil) + + #expect(probeCount == 1) + let completions = pendingProbeCompletions + for completion in completions { + completion() + } + let firstOutcome = await firstCheck.value + let secondOutcome = await secondCheck.value + + #expect(firstOutcome == .responsive) + #expect(secondOutcome == .responsive) + #expect(probeCount == 1) + #expect(recoveryCount == 0) + probeStartsContinuation.finish() + followerJoinsContinuation.finish() + } + + @Test("Cancelling the leading check cancels callers sharing its recovery") + func leadingCancellationCancelsSharedRecovery() async { + var probeCount = 0 + let (probeStarts, probeStartsContinuation) = AsyncStream.makeStream( + of: Void.self, + bufferingPolicy: .bufferingOldest(2) + ) + var probeStartsIterator = probeStarts.makeAsyncIterator() + let (followerJoins, followerJoinsContinuation) = AsyncStream.makeStream(of: Void.self) + var followerJoinsIterator = followerJoins.makeAsyncIterator() + let watchdog = BrowserAutomationWatchdog() + let observedInstanceID = UUID() + let probe: BrowserAutomationWatchdog.Probe = { _ in + probeCount += 1 + probeStartsContinuation.yield() + } + let recover: BrowserAutomationWatchdog.Recovery = { true } + + let firstCheck = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: recover + ) + } + let firstProbeStarted: Void? = await probeStartsIterator.next() + #expect(firstProbeStarted != nil) + + let secondCheck = Task { @MainActor in + followerJoinsContinuation.yield() + return await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: recover + ) + } + let followerJoined: Void? = await followerJoinsIterator.next() + #expect(followerJoined != nil) + firstCheck.cancel() + + let firstOutcome = await firstCheck.value + let secondOutcome = await secondCheck.value + #expect(firstOutcome == .cancelled) + #expect(secondOutcome == .cancelled) + #expect(probeCount == 1) + probeStartsContinuation.finish() + followerJoinsContinuation.finish() + } + + @Test("Cancelling a joined check does not retain it until the leader finishes") + func followerCancellationDoesNotWaitForLeader() async { + var probeCompletion: (@MainActor @Sendable () -> Void)? + let (probeStarts, probeStartsContinuation) = AsyncStream.makeStream(of: Void.self) + var probeStartsIterator = probeStarts.makeAsyncIterator() + let (followerJoins, followerJoinsContinuation) = AsyncStream.makeStream(of: Void.self) + var followerJoinsIterator = followerJoins.makeAsyncIterator() + let watchdog = BrowserAutomationWatchdog() + let observedInstanceID = UUID() + let probe: BrowserAutomationWatchdog.Probe = { finish in + probeCompletion = finish + probeStartsContinuation.yield() + } + + let leader = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: { true } + ) + } + let probeStarted: Void? = await probeStartsIterator.next() + #expect(probeStarted != nil) + + let follower = Task { @MainActor in + followerJoinsContinuation.yield() + return await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: { true } + ) + } + let followerJoined: Void? = await followerJoinsIterator.next() + #expect(followerJoined != nil) + follower.cancel() + + #expect(await follower.value == .cancelled) + probeCompletion?() + #expect(await leader.value == .responsive) + probeStartsContinuation.finish() + followerJoinsContinuation.finish() + } + + @Test("Owner invalidation cancels joined checks without recovering the stale instance") + func ownerInvalidationSupersedesSharedRecovery() async { + var probeCompletion: (@MainActor @Sendable () -> Void)? + var recoveryCount = 0 + let (probeStarts, probeStartsContinuation) = AsyncStream.makeStream(of: Void.self) + var probeStartsIterator = probeStarts.makeAsyncIterator() + let (followerJoins, followerJoinsContinuation) = AsyncStream.makeStream(of: Void.self) + var followerJoinsIterator = followerJoins.makeAsyncIterator() + let watchdog = BrowserAutomationWatchdog() + let observedInstanceID = UUID() + let probe: BrowserAutomationWatchdog.Probe = { finish in + probeCompletion = finish + probeStartsContinuation.yield() + } + + let leader = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: { + recoveryCount += 1 + return true + } + ) + } + let probeStarted: Void? = await probeStartsIterator.next() + #expect(probeStarted != nil) + + let follower = Task { @MainActor in + followerJoinsContinuation.yield() + return await watchdog.recoverIfUnresponsive( + observedInstanceID: observedInstanceID, + probes: [probe], + recover: { + recoveryCount += 1 + return true + } + ) + } + let followerJoined: Void? = await followerJoinsIterator.next() + #expect(followerJoined != nil) + watchdog.invalidate() + + #expect(await follower.value == .cancelled) + probeCompletion?() + #expect(await leader.value == .superseded) + #expect(recoveryCount == 0) + probeStartsContinuation.finish() + followerJoinsContinuation.finish() + } + + @Test("A newer browser instance supersedes every caller checking the old instance") + func newerInstanceSupersedesOldRecovery() async { + var firstProbeCount = 0 + var firstRecoveryCount = 0 + var firstProbeCompletion: (@MainActor @Sendable () -> Void)? + var secondRecoveryCount = 0 + var secondProbeCompletion: (@MainActor @Sendable () -> Void)? + let (probeStarts, probeStartsContinuation) = AsyncStream.makeStream( + of: UUID.self, + bufferingPolicy: .bufferingOldest(2) + ) + var probeStartsIterator = probeStarts.makeAsyncIterator() + let (followerJoins, followerJoinsContinuation) = AsyncStream.makeStream(of: Void.self) + var followerJoinsIterator = followerJoins.makeAsyncIterator() + let watchdog = BrowserAutomationWatchdog() + let firstInstanceID = UUID() + let secondInstanceID = UUID() + let firstProbe: BrowserAutomationWatchdog.Probe = { finish in + firstProbeCount += 1 + firstProbeCompletion = finish + probeStartsContinuation.yield(firstInstanceID) + } + let secondProbe: BrowserAutomationWatchdog.Probe = { finish in + secondProbeCompletion = finish + probeStartsContinuation.yield(secondInstanceID) + } + + let firstLeader = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: firstInstanceID, + probes: [firstProbe], + recover: { + firstRecoveryCount += 1 + return true + } + ) + } + let firstStarted = await probeStartsIterator.next() + #expect(firstStarted == firstInstanceID) + + let firstFollower = Task { @MainActor in + followerJoinsContinuation.yield() + return await watchdog.recoverIfUnresponsive( + observedInstanceID: firstInstanceID, + probes: [firstProbe], + recover: { + firstRecoveryCount += 1 + return true + } + ) + } + let followerJoined: Void? = await followerJoinsIterator.next() + #expect(followerJoined != nil) + #expect(firstProbeCount == 1) + + let secondLeader = Task { @MainActor in + await watchdog.recoverIfUnresponsive( + observedInstanceID: secondInstanceID, + probes: [secondProbe], + recover: { + secondRecoveryCount += 1 + return true + } + ) + } + let secondStarted = await probeStartsIterator.next() + #expect(secondStarted == secondInstanceID) + #expect(await firstFollower.value == .superseded) + + secondProbeCompletion?() + #expect(await secondLeader.value == .responsive) + + firstProbeCompletion?() + #expect(await firstLeader.value == .superseded) + #expect(firstRecoveryCount == 0) + #expect(secondRecoveryCount == 0) + probeStartsContinuation.finish() + followerJoinsContinuation.finish() + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 296bf29e55aa..62b887231fef 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -19213,6 +19213,506 @@ } } }, + "browser.automation.error.documentReadinessTimedOut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة انتظار جاهزية مستند المتصفح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isteklo je vrijeme čekanja da dokument preglednika bude spreman." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tidsfristen for at vente på, at browserdokumentet blev klar, udløb." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung beim Warten auf die Bereitschaft des Browserdokuments." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out waiting for the browser document to become ready" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo de espera para que el documento del navegador estuviera listo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le délai d’attente de la disponibilité du document du navigateur a expiré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tempo scaduto durante l'attesa che il documento del browser fosse pronto." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザドキュメントの準備完了を待機中にタイムアウトしました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អស់ពេលរង់ចាំឱ្យឯកសារកម្មវិធីរុករករួចរាល់។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 문서가 준비되기를 기다리는 동안 시간이 초과되었습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tidsavbrudd under venting på at nettleserdokumentet skulle bli klart." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przekroczono limit czasu oczekiwania na gotowość dokumentu przeglądarki." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O tempo de espera para que o documento do navegador ficasse pronto expirou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Истекло время ожидания готовности документа браузера." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หมดเวลารอให้เอกสารเบราว์เซอร์พร้อม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı belgesinin hazır olması beklenirken zaman aşımına uğradı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевищено час очікування готовності документа браузера." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待浏览器文档就绪超时。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待瀏覽器文件就緒逾時。" + } + } + } + }, + "browser.automation.error.javaScriptTimedOut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة انتظار نتيجة JavaScript." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isteklo je vrijeme čekanja na JavaScript rezultat." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tidsfristen for at vente på JavaScript-resultatet udløb." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung beim Warten auf das JavaScript-Ergebnis." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out waiting for JavaScript result" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo de espera del resultado de JavaScript." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le délai d’attente du résultat JavaScript a expiré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tempo scaduto durante l'attesa del risultato JavaScript." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "JavaScript の結果を待機中にタイムアウトしました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អស់ពេលរង់ចាំលទ្ធផល JavaScript។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "JavaScript 결과를 기다리는 동안 시간이 초과되었습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tidsavbrudd under venting på JavaScript-resultatet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przekroczono limit czasu oczekiwania na wynik JavaScript." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O tempo de espera pelo resultado do JavaScript expirou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Истекло время ожидания результата JavaScript." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หมดเวลารอผลลัพธ์ JavaScript" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "JavaScript sonucu beklenirken zaman aşımına uğradı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевищено час очікування результату JavaScript." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待 JavaScript 结果超时。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待 JavaScript 結果逾時。" + } + } + } + }, + "browser.automation.error.recovered": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف سطح المتصفح عن الاستجابة وتمت استعادته. أعد محاولة الأمر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Površina preglednika prestala je odgovarati i oporavljena je. Pokušajte naredbu ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserfladen holdt op med at svare og blev gendannet. Prøv kommandoen igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Browseroberfläche reagierte nicht mehr und wurde wiederhergestellt. Versuche den Befehl erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The browser surface stopped responding and was recovered. Retry the command." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La superficie del navegador dejó de responder y se recuperó. Vuelve a intentar el comando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La surface du navigateur ne répondait plus et a été restaurée. Réessayez la commande." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La superficie del browser ha smesso di rispondere ed è stata ripristinata. Riprova il comando." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザサーフェスが応答しなくなったため復旧しました。コマンドを再試行してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទៃកម្មវិធីរុករកបានឈប់ឆ្លើយតប ហើយត្រូវបានស្ដារឡើងវិញ។ សូមសាកល្បងពាក្យបញ្ជាម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 화면이 응답하지 않아 복구되었습니다. 명령을 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleserflaten sluttet å svare og ble gjenopprettet. Prøv kommandoen på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powierzchnia przeglądarki przestała odpowiadać i została odzyskana. Ponów polecenie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A superfície do navegador parou de responder e foi recuperada. Tente o comando novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поверхность браузера перестала отвечать и была восстановлена. Повторите команду." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวเบราว์เซอร์หยุดตอบสนองและได้รับการกู้คืนแล้ว โปรดลองใช้คำสั่งอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı yüzeyi yanıt vermeyi durdurdu ve kurtarıldı. Komutu yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поверхня браузера перестала відповідати та була відновлена. Повторіть команду." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器表面停止响应,现已恢复。请重试该命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器介面停止回應,現已復原。請重試該命令。" + } + } + } + }, + "browser.automation.error.superseded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت استعادة سطح المتصفح بالفعل. أعد محاولة الأمر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Površina preglednika je već oporavljena. Pokušajte naredbu ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browserfladen er allerede blevet gendannet. Prøv kommandoen igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Browseroberfläche wurde bereits wiederhergestellt. Versuche den Befehl erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The browser surface was already recovered. Retry the command." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La superficie del navegador ya se recuperó. Vuelve a intentar el comando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La surface du navigateur a déjà été restaurée. Réessayez la commande." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La superficie del browser è già stata ripristinata. Riprova il comando." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザサーフェスはすでに復旧されています。コマンドを再試行してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទៃកម្មវិធីរុករកត្រូវបានស្ដារឡើងវិញរួចហើយ។ សូមសាកល្បងពាក្យបញ្ជាម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 화면이 이미 복구되었습니다. 명령을 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleserflaten er allerede gjenopprettet. Prøv kommandoen på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powierzchnia przeglądarki została już odzyskana. Ponów polecenie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A superfície do navegador já foi recuperada. Tente o comando novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поверхность браузера уже восстановлена. Повторите команду." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "พื้นผิวเบราว์เซอร์ได้รับการกู้คืนแล้ว โปรดลองใช้คำสั่งอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı yüzeyi zaten kurtarıldı. Komutu yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поверхню браузера вже відновлено. Повторіть команду." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器表面已恢复。请重试该命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器介面已復原。請重試該命令。" + } + } + } + }, "browser.contextMenu.openLinkInDefaultBrowser": { "extractionState": "manual", "localizations": { @@ -28926,6 +29426,131 @@ } } }, + "browser.screenshot.error.automationTimedOut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة انتظار لقطة شاشة المتصفح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isteklo je vrijeme čekanja na snimak ekrana preglednika." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tidsgrænsen for at vente på browserskærmbilledet blev overskredet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung beim Warten auf den Browser-Screenshot." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out waiting for the browser screenshot." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo de espera de la captura de pantalla del navegador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délai d’attente dépassé pour la capture d’écran du navigateur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tempo scaduto in attesa dello screenshot del browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザのスクリーンショットを待機中にタイムアウトしました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អស់ពេលរង់ចាំរូបថតអេក្រង់កម្មវិធីរុករក។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 스크린샷을 기다리는 동안 시간이 초과되었습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tidsavbrudd mens det ble ventet på skjermbildet fra nettleseren." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Upłynął limit czasu oczekiwania na zrzut ekranu przeglądarki." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O tempo limite de espera pela captura de tela do navegador foi excedido." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время ожидания снимка экрана браузера истекло." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หมดเวลารอภาพหน้าจอของเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı ekran görüntüsü beklenirken zaman aşımına uğradı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Час очікування знімка екрана браузера минув." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待浏览器截图超时。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待瀏覽器螢幕截圖逾時。" + } + } + } + }, "browser.screenshot.error.captureAreaTooLarge": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserAutomationProbeChannel.swift b/Sources/Panels/BrowserAutomationProbeChannel.swift new file mode 100644 index 000000000000..2408f83b6edb --- /dev/null +++ b/Sources/Panels/BrowserAutomationProbeChannel.swift @@ -0,0 +1,11 @@ +enum BrowserAutomationProbeChannel: Sendable { + case javaScript + case screenshot + + var debugName: String { + switch self { + case .javaScript: "javascript" + case .screenshot: "screenshot" + } + } +} diff --git a/Sources/Panels/BrowserAutomationSnapshotResult.swift b/Sources/Panels/BrowserAutomationSnapshotResult.swift new file mode 100644 index 000000000000..862e5f5843bc --- /dev/null +++ b/Sources/Panels/BrowserAutomationSnapshotResult.swift @@ -0,0 +1,7 @@ +import Foundation + +enum BrowserAutomationSnapshotResult: Sendable { + case success(Data) + case failure(String) + case timedOut +} diff --git a/Sources/Panels/BrowserJavaScriptEvaluationResult.swift b/Sources/Panels/BrowserJavaScriptEvaluationResult.swift new file mode 100644 index 000000000000..5ac7e8b9de66 --- /dev/null +++ b/Sources/Panels/BrowserJavaScriptEvaluationResult.swift @@ -0,0 +1,6 @@ +/// Distinguishes a missing WebKit callback from JavaScript errors that returned normally. +enum BrowserJavaScriptEvaluationResult { + case success(Any?) + case failure(String) + case timedOut +} diff --git a/Sources/Panels/BrowserPanel+AutomationRecovery.swift b/Sources/Panels/BrowserPanel+AutomationRecovery.swift new file mode 100644 index 000000000000..3eaf1179866f --- /dev/null +++ b/Sources/Panels/BrowserPanel+AutomationRecovery.swift @@ -0,0 +1,129 @@ +import AppKit +import CmuxBrowser +import WebKit + +extension BrowserPanel { + func registerBrowserAutomationInitScript(_ userScript: WKUserScript) -> Int { + browserAutomationUserScripts.append(userScript) + browserAutomationInitScriptCount += 1 + webView.configuration.userContentController.addUserScript(userScript) + return browserAutomationInitScriptCount + } + + func registerBrowserAutomationStyleScript(_ userScript: WKUserScript) -> Int { + browserAutomationUserScripts.append(userScript) + browserAutomationStyleScriptCount += 1 + webView.configuration.userContentController.addUserScript(userScript) + return browserAutomationStyleScriptCount + } + + func clearBrowserAutomationUserScripts() { + browserAutomationUserScripts.removeAll() + browserAutomationInitScriptCount = 0 + browserAutomationStyleScriptCount = 0 + } + + func makeReplacementWebView( + profileID: UUID, + websiteDataStore: WKWebsiteDataStore + ) -> CmuxWebView { + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) + for userScript in browserAutomationUserScripts { + replacement.configuration.userContentController.addUserScript(userScript) + } + return replacement + } + + var canRecoverFromAutomationTimeout: Bool { + !isClosingWebViewLifecycle && + activeInteractiveBrowserPromptIDs.isEmpty && + activeVisualAutomationCaptureCount == 0 + } + + func waitForAutomationDocumentCommit( + expectedWebViewIdentifier: ObjectIdentifier + ) async -> BrowserAutomationDocumentReadinessOutcome { + guard ObjectIdentifier(webView) == expectedWebViewIdentifier else { return .superseded } + return await automationDocumentReadiness.waitForCommit(instanceID: webViewInstanceID) + } + + func recoverIfAutomationUnresponsive( + expectedWebViewIdentifier: ObjectIdentifier, + channel: BrowserAutomationProbeChannel + ) async -> BrowserAutomationRecoveryOutcome { + guard ObjectIdentifier(webView) == expectedWebViewIdentifier else { return .superseded } + guard canRecoverFromAutomationTimeout else { return .responsive } + let observedWebViewInstanceID = webViewInstanceID + + let asyncJavaScriptProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + webView.callAsyncJavaScript( + "return true", + arguments: [:], + in: nil, + in: .page + ) { _ in finish() } + } + let evaluationProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + webView.evaluateJavaScript("void 0") { _, _ in finish() } + } + let snapshotProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + let configuration = WKSnapshotConfiguration() + configuration.rect = NSRect(x: 0, y: 0, width: 1, height: 1) + webView.takeSnapshot(with: configuration) { _, _ in finish() } + } + let outcome = await automationWatchdog.recoverIfUnresponsive( + observedInstanceID: observedWebViewInstanceID, + // One WebContent process services every automation API. Probing all callback channels + // lets JavaScript and screenshot callers safely share this single in-flight check. + probes: [asyncJavaScriptProbe, evaluationProbe, snapshotProbe], + recover: { [weak self] in + self?.replaceWebViewAfterAutomationTimeout( + expectedWebViewIdentifier: expectedWebViewIdentifier, + reason: "automation_\(channel.debugName)_unresponsive" + ) ?? false + } + ) + + if outcome == .responsive, + (ObjectIdentifier(webView) != expectedWebViewIdentifier + || webViewInstanceID != observedWebViewInstanceID) { + return .superseded + } + return outcome + } + + @discardableResult + func replaceWebViewAfterAutomationTimeout( + expectedWebViewIdentifier: ObjectIdentifier, + reason: String + ) -> Bool { + guard ObjectIdentifier(webView) == expectedWebViewIdentifier, canRecoverFromAutomationTimeout else { return false } + replaceWebViewPreservingState( + from: webView, + websiteDataStore: websiteDataStore, + reason: reason + ) + return true + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 99c91a5775e5..bf00d595153e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1841,15 +1841,6 @@ enum BrowserInsecureHTTPNavigationResolution { } } -enum BrowserWebViewLifecycleState: String { - case newTab = "new_tab" - case deferredURL = "deferred_url" - case liveVisible = "live_visible" - case liveHidden = "live_hidden" - case discarded - case closing -} - final class CmuxDiffViewerURLSchemeHandler: NSObject, WKURLSchemeHandler { static let scheme = "cmux-diff-viewer" static let shared = CmuxDiffViewerURLSchemeHandler() @@ -2738,7 +2729,10 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView - private var websiteDataStore: WKWebsiteDataStore + var websiteDataStore: WKWebsiteDataStore + var browserAutomationUserScripts: [WKUserScript] = [] + var browserAutomationInitScriptCount = 0 + var browserAutomationStyleScriptCount = 0 var webViewDidRequestClose: (() -> Void)? /// Monotonic identity for the current WKWebView instance. @@ -2815,7 +2809,9 @@ final class BrowserPanel: Panel, ObservableObject { private var shouldPreloadInitialNavigationInBackground: Bool private var backgroundPreloadWindow: NSWindow? private let visualAutomationCaptureGate = BrowserScreenshotCaptureGate() - private var activeVisualAutomationCaptureCount: Int = 0 + let automationWatchdog = BrowserAutomationWatchdog() + let automationDocumentReadiness = BrowserAutomationDocumentReadiness() + var activeVisualAutomationCaptureCount: Int = 0 private struct PendingInteractiveBrowserPrompt { let present: (NSWindow, @escaping () -> Void) -> Void let cancel: () -> Void @@ -3014,7 +3010,6 @@ final class BrowserPanel: Panel, ObservableObject { private var loadingStartedAt: Date? private var loadingEndWorkItem: DispatchWorkItem? private var loadingGeneration: Int = 0 - private var faviconTask: Task? private var faviconRefreshGeneration: Int = 0 private var lastFaviconURLString: String? @@ -3022,6 +3017,7 @@ final class BrowserPanel: Panel, ObservableObject { private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? + var activeInteractiveBrowserPromptIDs: Set = [] var insecureHTTPAlertFactory: () -> NSAlert var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). @@ -3325,7 +3321,7 @@ final class BrowserPanel: Panel, ObservableObject { oldWebView.uiDelegate = nil if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } - let replacement = Self.makeWebView( + let replacement = makeReplacementWebView( profileID: profileID, websiteDataStore: websiteDataStore ) @@ -3696,6 +3692,7 @@ final class BrowserPanel: Panel, ObservableObject { self?.openLinkInNewTab(url: url) } configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks() + automationDocumentReadiness.bind(to: webViewInstanceID, hasCommittedDocument: webView.backForwardList.currentItem != nil) webView.cmuxDownloadDelegate = downloadDelegate webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate @@ -3706,7 +3703,6 @@ final class BrowserPanel: Panel, ObservableObject { webAuthnCoordinator.install(on: webView) applyMuteState(to: webView, reason: "bindWebView") } - private func setupSSLTrustBypassMessageHandler(for webView: WKWebView) { let handler = BrowserSSLTrustBypassMessageHandler( canHandleToken: { [weak self] token in @@ -3722,7 +3718,6 @@ final class BrowserPanel: Panel, ObservableObject { userContentController.removeScriptMessageHandler(forName: BrowserSSLTrustBypassMessageHandler.name) userContentController.add(handler, name: BrowserSSLTrustBypassMessageHandler.name) } - private func configureNavigationDelegateCallbacks() { guard let navigationDelegate else { return } let boundWebViewInstanceID = webViewInstanceID @@ -3750,6 +3745,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } (webView as? CmuxWebView)?.diffViewerNavigationDidCommit(navigation) self.isMainFrameProvisionalNavigationActive = false + self.automationDocumentReadiness.didCommit(instanceID: boundWebViewInstanceID) // An about:blank placeholder leaves the restore-stall detector armed. if !Self.isAboutBlankURL(webView.url) { self.hasCommittedDocumentSinceWebViewReplacement = true @@ -3849,7 +3845,6 @@ final class BrowserPanel: Panel, ObservableObject { refreshBackgroundAppearance() GlobalSearchCoordinator.shared.captureBrowserPanel(self) } - private func noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: UUID?) { guard let restoreAttemptID, restoreAttemptID == currentDiscardRestoreAttemptID else { return } hasCommittedDocumentSinceWebViewReplacement = true @@ -4287,37 +4282,48 @@ final class BrowserPanel: Panel, ObservableObject { private func presentBrowserAlert( _ alert: NSAlert, in webView: WKWebView, + windowProvider: (() -> NSWindow?)? = nil, completion: @escaping (NSApplication.ModalResponse) -> Void, cancel: @escaping () -> Void ) { - if let window = browserInteractiveModalHostWindow(for: webView) { - alert.beginSheetModal(for: window, completionHandler: completion) + let promptID = UUID() + activeInteractiveBrowserPromptIDs.insert(promptID) + let trackedCompletion: (NSApplication.ModalResponse) -> Void = { [weak self] response in self?.activeInteractiveBrowserPromptIDs.remove(promptID); completion(response) } + let trackedCancel = { [weak self] in self?.activeInteractiveBrowserPromptIDs.remove(promptID); cancel() } + let shouldDeferPrompt = shouldDeferPromptUntilInteractiveHost(for: webView) + if windowProvider == nil, let window = browserInteractiveModalHostWindow(for: webView) { + alert.beginSheetModal(for: window, completionHandler: trackedCompletion) return } - - guard shouldDeferPromptUntilInteractiveHost(for: webView) else { - browserPresentAlert(alert, in: webView, completion: completion, cancel: cancel) + if !shouldDeferPrompt, let windowProvider { + if let window = windowProvider() { + alert.beginSheetModal(for: window, completionHandler: trackedCompletion) + } else { + trackedCompletion(alert.runModal()) + } return } - - pendingInteractiveBrowserPrompts.append( - PendingInteractiveBrowserPrompt( - present: { sheetWindow, didFinish in - alert.beginSheetModal(for: sheetWindow) { response in - completion(response) - didFinish() - } - }, - cancel: cancel + if shouldDeferPrompt { + pendingInteractiveBrowserPrompts.append( + PendingInteractiveBrowserPrompt( + present: { sheetWindow, didFinish in + alert.beginSheetModal(for: sheetWindow) { response in + trackedCompletion(response) + didFinish() + } + }, + cancel: trackedCancel + ) ) - ) - #if DEBUG - cmuxDebugLog( - "browser.prompt.queue panel=\(id.uuidString.prefix(5)) " + - "pending=\(pendingInteractiveBrowserPrompts.count)" - ) + cmuxDebugLog( + "browser.prompt.queue panel=\(id.uuidString.prefix(5)) " + + "pending=\(pendingInteractiveBrowserPrompts.count)" + ) #endif + return + } + browserPresentAlert(alert, in: webView, completion: trackedCompletion, cancel: trackedCancel) } private func drainPendingInteractiveBrowserPromptsIfPossible(reason: String) { @@ -4568,6 +4574,7 @@ final class BrowserPanel: Panel, ObservableObject { remoteProxyEndpoint = bypassesRemoteWorkspaceProxy ? nil : proxyEndpoint remoteWorkspaceStatus = remoteStatus if needsStoreSwap { + clearBrowserAutomationUserScripts() replaceWebViewPreservingState( from: webView, websiteDataStore: targetStore, @@ -4628,6 +4635,7 @@ final class BrowserPanel: Panel, ObservableObject { websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) } + clearBrowserAutomationUserScripts() let replacement = Self.makeWebView( profileID: resolvedProfileID, websiteDataStore: websiteDataStore @@ -5078,8 +5086,7 @@ final class BrowserPanel: Panel, ObservableObject { waitForManualRecovery: true ) } - - private func replaceWebViewPreservingState( + func replaceWebViewPreservingState( from oldWebView: WKWebView, websiteDataStore: WKWebsiteDataStore, reason: String, @@ -5137,7 +5144,7 @@ final class BrowserPanel: Panel, ObservableObject { oldWebView.uiDelegate = nil if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } - let replacement = Self.makeWebView( + let replacement = makeReplacementWebView( profileID: profileID, websiteDataStore: websiteDataStore ) @@ -5305,6 +5312,8 @@ final class BrowserPanel: Panel, ObservableObject { func close() { cancelHiddenWebViewDiscard() isClosingWebViewLifecycle = true + automationDocumentReadiness.invalidate() + automationWatchdog.invalidate() refreshWebViewLifecycleState() GlobalSearchCoordinator.shared.purgePanel(id: id) closeDeveloperToolsForTeardown() @@ -5898,7 +5907,6 @@ final class BrowserPanel: Panel, ObservableObject { ) { guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } - let alert = insecureHTTPAlertFactory() alert.alertStyle = .warning alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") @@ -5922,17 +5930,7 @@ final class BrowserPanel: Panel, ObservableObject { ) } - if shouldDeferPromptUntilInteractiveHost(for: webView) { - presentBrowserAlert(alert, in: webView, completion: handleResponse, cancel: {}) - return - } - - if let alertWindow = insecureHTTPAlertWindowProvider() { - alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse) - return - } - - handleResponse(alert.runModal()) + presentBrowserAlert(alert, in: webView, windowProvider: insecureHTTPAlertWindowProvider, completion: handleResponse, cancel: { onResolution(.cancelled) }) } func handleInsecureHTTPAlertResponse( @@ -6148,6 +6146,7 @@ extension BrowserPanel { oldWebView.uiDelegate = nil if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } + clearBrowserAutomationUserScripts() let replacement = Self.makeWebView( profileID: profileID, websiteDataStore: websiteDataStore @@ -7110,7 +7109,7 @@ extension BrowserPanel { } timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in - finish(.failure(BrowserScreenshotError.emptySnapshot)) + finish(.failure(BrowserScreenshotError.automationTimedOut)) } BrowserScreenshotWebViewSnapshotter.prepareForVisualCapture( @@ -7119,6 +7118,7 @@ extension BrowserPanel { ) { result in switch result { case .success: + guard !didFinish else { return } operation(captureWebView, false, finish) case .failure(let error): finish(.failure(error)) diff --git a/Sources/Panels/BrowserScreenshotPipeline.swift b/Sources/Panels/BrowserScreenshotPipeline.swift index 2e475f81beac..e813caa51e8f 100644 --- a/Sources/Panels/BrowserScreenshotPipeline.swift +++ b/Sources/Panels/BrowserScreenshotPipeline.swift @@ -2,6 +2,7 @@ import AppKit import UniformTypeIdentifiers enum BrowserScreenshotError: LocalizedError { + case automationTimedOut case captureAreaTooLarge case emptySnapshot case invalidSelection @@ -11,6 +12,11 @@ enum BrowserScreenshotError: LocalizedError { var errorDescription: String? { switch self { + case .automationTimedOut: + return String( + localized: "browser.screenshot.error.automationTimedOut", + defaultValue: "Timed out waiting for the browser screenshot." + ) case .captureAreaTooLarge: return String( localized: "browser.screenshot.error.captureAreaTooLarge", diff --git a/Sources/Panels/BrowserScreenshotSnapshotter.swift b/Sources/Panels/BrowserScreenshotSnapshotter.swift index cba41db4081a..c30ba7800b6c 100644 --- a/Sources/Panels/BrowserScreenshotSnapshotter.swift +++ b/Sources/Panels/BrowserScreenshotSnapshotter.swift @@ -338,12 +338,12 @@ enum BrowserScreenshotWebViewSnapshotter { } timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in - finish(.failure(BrowserScreenshotError.emptySnapshot)) + finish(.failure(BrowserScreenshotError.automationTimedOut)) } - prepareForVisualCapture(webView, expectedURL: expectedURL) { result in switch result { case .success: + guard !didFinish else { return } operation(finish) case .failure(let error): finish(.failure(error)) diff --git a/Sources/Panels/BrowserWebViewLifecycleState.swift b/Sources/Panels/BrowserWebViewLifecycleState.swift new file mode 100644 index 000000000000..2f920dcf63d9 --- /dev/null +++ b/Sources/Panels/BrowserWebViewLifecycleState.swift @@ -0,0 +1,8 @@ +nonisolated enum BrowserWebViewLifecycleState: String { + case newTab = "new_tab" + case deferredURL = "deferred_url" + case liveVisible = "live_visible" + case liveHidden = "live_hidden" + case discarded + case closing +} diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index d550c80c418c..05bd43415e9b 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -70,6 +70,10 @@ struct PanelContentView: View { paneOwnershipOverride: paneOwnershipOverride, onRequestPanelFocus: onRequestPanelFocus ) + // Browser chrome owns panel-scoped edit/focus state. Bonsplit reuses this + // structural slot when a pane selects another browser, so bind its lifetime + // to the panel instead of carrying the prior panel's omnibar draft forward. + .id(browserPanel.id) } case .markdown: if let markdownPanel = panel as? MarkdownPanel { diff --git a/Sources/TerminalController+BrowserAutomationRecovery.swift b/Sources/TerminalController+BrowserAutomationRecovery.swift new file mode 100644 index 000000000000..7864cb778ae1 --- /dev/null +++ b/Sources/TerminalController+BrowserAutomationRecovery.swift @@ -0,0 +1,106 @@ +import CmuxBrowser +import Foundation +import WebKit + +extension TerminalController { + nonisolated func v2CaptureBrowserAutomationSnapshot( + _ browserPanel: BrowserPanel, + timeout: TimeInterval + ) -> (webViewIdentifier: ObjectIdentifier, result: BrowserAutomationSnapshotResult)? { + socketAwaitCallback(timeout: timeout) { finish in + v2MainSync { + let webViewIdentifier = ObjectIdentifier(browserPanel.webView) + browserPanel.captureAutomationVisibleViewportSnapshot { result in + switch result { + case .success(let image): + guard let data = self.v2PNGData(from: image) else { + finish((webViewIdentifier, .failure(BrowserScreenshotError.invalidImageRepresentation.localizedDescription))) + return + } + finish((webViewIdentifier, .success(data))) + case .failure(let error as BrowserScreenshotError): + if case .automationTimedOut = error { + finish((webViewIdentifier, .timedOut)) + } else { + finish((webViewIdentifier, .failure(error.localizedDescription))) + } + case .failure(let error): + finish((webViewIdentifier, .failure(error.localizedDescription))) + } + } + } + } + } + + nonisolated func v2RecoverTimedOutBrowserJavaScript( + _ result: BrowserJavaScriptEvaluationResult, + webView: WKWebView, + browserPanel: BrowserPanel, + surfaceId: UUID + ) -> V2JavaScriptResult { + switch result { + case .success(let value): + return .success(value) + case .failure(let message): + return .failure(message) + case .timedOut: + return .failure(v2BrowserAutomationMessageAfterLivenessCheck( + originalMessage: String( + localized: "browser.automation.error.javaScriptTimedOut", + defaultValue: "Timed out waiting for JavaScript result" + ), + browserPanel: browserPanel, + surfaceId: surfaceId, + expectedWebViewIdentifier: ObjectIdentifier(webView), + channel: .javaScript + )) + } + } + + nonisolated func v2BrowserAutomationMessageAfterLivenessCheck( + originalMessage: String, + browserPanel: BrowserPanel, + surfaceId: UUID, + expectedWebViewIdentifier: ObjectIdentifier, + channel: BrowserAutomationProbeChannel + ) -> String { + var recoveryTask: Task? + let outcome: BrowserAutomationRecoveryOutcome? = socketAwaitCallback(timeout: 2.5) { finish in + recoveryTask = Task { @MainActor in + guard !Task.isCancelled else { + finish(.cancelled) + return + } + let result = await browserPanel.recoverIfAutomationUnresponsive( + expectedWebViewIdentifier: expectedWebViewIdentifier, + channel: channel + ) +#if DEBUG + cmuxDebugLog( + "browser.automation.liveness surface=\(surfaceId.uuidString.prefix(5)) " + + "channel=\(channel.debugName) outcome=\(String(describing: result))" + ) +#endif + finish(result) + } + } + if outcome == nil { + recoveryTask?.cancel() + } + + switch outcome { + case .recovered: + return String( + localized: "browser.automation.error.recovered", + defaultValue: "The browser surface stopped responding and was recovered. Retry the command." + ) + case .superseded: + return String( + localized: "browser.automation.error.superseded", + defaultValue: "The browser surface was already recovered. Retry the command." + ) + case .responsive, .cancelled, nil: + return originalMessage + } + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 90ce535c148b..1e095dc5b0cf 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -305,8 +305,6 @@ class TerminalController { private var v2BrowserNextElementOrdinal: Int = 1 private var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] = [:] private var v2BrowserFrameSelectorBySurface: [UUID: String] = [:] - private var v2BrowserInitScriptsBySurface: [UUID: [String]] = [:] - private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:] private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:] private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] private var v2ConsumedBrowserDownloadKeysBySurface: [UUID: [String]] = [:] @@ -328,8 +326,6 @@ class TerminalController { func cleanupSurfaceState(surfaceIds: [UUID], paneIds: [UUID] = []) { for surfaceId in Set(surfaceIds) { v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId) - v2BrowserInitScriptsBySurface.removeValue(forKey: surfaceId) - v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId) v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId) v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId) v2ConsumedBrowserDownloadKeysBySurface.removeValue(forKey: surfaceId) @@ -3866,7 +3862,9 @@ class TerminalController { requestedWorkspaceId: requestedWorkspaceId, preferredSurfaceId: surfaceSelection.surfaceId ) - if let error = resolved.error { return error } + if let error = resolved.error { + return error + } guard let target = resolved.target else { return .err(code: "not_found", message: "Workspace not found", data: nil) } guard let controller = target.controller else { return .err(code: "remote_pty_error", message: "remote connection is not active", data: ["workspace_id": target.workspaceId.uuidString, "workspace_ref": target.workspaceRef]) @@ -5732,7 +5730,7 @@ class TerminalController { timeout: TimeInterval = 5.0, preferAsync: Bool = false, world: V2JSContentWorld - ) -> V2JavaScriptResult { + ) -> BrowserJavaScriptEvaluationResult { let timeoutSeconds = max(0.01, timeout) // Capture the held browser-control service (a Sendable value) rather than // `self`, reusing the already-initialized instance for error description. @@ -5788,7 +5786,7 @@ class TerminalController { "world=\(world == .page ? "page" : "isolated") timeout=\(timeoutSeconds)" ) #endif - return .failure("Timed out waiting for JavaScript result") + return .timedOut } if let resultError = outcome.1 { return .failure(resultError) @@ -5805,6 +5803,7 @@ class TerminalController { private nonisolated func v2WaitForBrowserCondition( _ webView: WKWebView, + browserPanel: BrowserPanel, surfaceId: UUID, conditionScript: String, timeoutMs: Int @@ -5876,6 +5875,7 @@ class TerminalController { switch v2RunBrowserJavaScript( webView, + browserPanel: browserPanel, surfaceId: surfaceId, script: waitScript, timeout: timeout + 1.0, @@ -5939,72 +5939,84 @@ class TerminalController { /// first evaluateJavaScript/callAsyncJavaScript call can hang for its full timeout. A /// URL-less browser surface never mounts its webview either (no render, no host window), /// so a raw webView.load() would not progress. Kick such surfaces through the panel's - /// normal navigate path (which hosts the webview) to about:blank, and wait for the URL - /// to commit (KVO, bounded by `timeout`) before any automation JS runs against them. + /// normal navigation path, then wait for that exact WebView instance's navigation-delegate + /// commit before any automation JavaScript runs against it. private nonisolated func v2EnsureBrowserDocumentLoaded( _ webView: WKWebView, + browserPanel: BrowserPanel, surfaceId: UUID, timeout: TimeInterval = 3.0 - ) { - let needsKick: Bool = v2MainSync { - guard webView.url == nil, - !webView.isLoading, - webView.backForwardList.currentItem == nil else { return false } - guard let located = AppDelegate.shared?.locateSurface(surfaceId: surfaceId), - let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - let browserPanel = workspace.browserPanel(for: surfaceId), - let blankURL = URL(string: "about:blank") else { + ) -> Bool { + let expectedWebViewIdentifier = ObjectIdentifier(webView) + var readinessTask: Task? + let outcome: BrowserAutomationDocumentReadinessOutcome? = v2AwaitCallback(timeout: timeout) { finish in + readinessTask = Task { @MainActor in + guard ObjectIdentifier(browserPanel.webView) == expectedWebViewIdentifier, + let blankURL = URL(string: "about:blank") else { #if DEBUG - cmuxDebugLog("browser.jsKick.locateFailed surface=\(surfaceId.uuidString.prefix(5))") + cmuxDebugLog("browser.jsCommit.locateFailed surface=\(surfaceId.uuidString.prefix(5))") #endif - return false - } - // Discarded/restored tabs also have a fresh nil-url webview but preserve - // the user's page; bring that back instead of clobbering it with blank. - if browserPanel.restoreDiscardedWebViewIfNeeded(reason: "automation-js") { - return true - } - if let preserved = browserPanel.currentURL { - browserPanel.navigate(to: preserved) - } else { - browserPanel.navigate(to: blankURL) - } - return true - } - guard needsKick else { return } - - // Register synchronously and invalidate after the await (both on main) so - // the observation cannot leak when the commit never arrives before timeout. - nonisolated(unsafe) var observation: NSKeyValueObservation? - let committed = v2AwaitCallback(timeout: timeout) { (finish: @escaping (Bool) -> Void) in - v2MainSync { - observation = webView.observe(\.url, options: [.initial, .new]) { observed, _ in - guard observed.url != nil else { return } - finish(true) + finish(.superseded) + return + } + let currentWebView = browserPanel.webView + + if currentWebView.url == nil, + !currentWebView.isLoading, + currentWebView.backForwardList.currentItem == nil { + // Discarded tabs preserve the user's page intent. Restore it before + // falling back to a real about:blank document for an empty new tab. + let restored = browserPanel.restoreDiscardedWebViewIfNeeded(reason: "automation-js") + if !restored, let preserved = browserPanel.currentURL { + browserPanel.navigate(to: preserved) + } else if !restored || BrowserPanel.isAboutBlankURL(browserPanel.currentURL) { + browserPanel.navigate(to: blankURL) + } } + + finish(await browserPanel.waitForAutomationDocumentCommit( + expectedWebViewIdentifier: expectedWebViewIdentifier + )) } } - v2MainSync { - observation?.invalidate() - observation = nil + if outcome == nil { + readinessTask?.cancel() } #if DEBUG cmuxDebugLog( - "browser.jsKick surface=\(surfaceId.uuidString.prefix(5)) " + - "committed=\(committed == true) url=\(v2MainSync { webView.url?.absoluteString ?? "nil" })" + "browser.jsCommit surface=\(surfaceId.uuidString.prefix(5)) " + + "outcome=\(String(describing: outcome)) url=\(v2MainSync { webView.url?.absoluteString ?? "nil" })" ) #endif + return outcome == .committed } private nonisolated func v2RunBrowserJavaScript( _ webView: WKWebView, + browserPanel: BrowserPanel, surfaceId: UUID, script: String, timeout: TimeInterval = 5.0, useEval: Bool = true, + requiresPageWorld: Bool = false, onIsolatedWorldFallback: (() -> Void)? = nil ) -> V2JavaScriptResult { - v2EnsureBrowserDocumentLoaded(webView, surfaceId: surfaceId) + guard v2EnsureBrowserDocumentLoaded( + webView, + browserPanel: browserPanel, + surfaceId: surfaceId + ) else { + return .failure(v2BrowserAutomationMessageAfterLivenessCheck( + originalMessage: String( + localized: "browser.automation.error.documentReadinessTimedOut", + defaultValue: "Timed out waiting for the browser document to become ready" + ), + browserPanel: browserPanel, + surfaceId: surfaceId, + expectedWebViewIdentifier: ObjectIdentifier(webView), + channel: .javaScript + )) + } let scriptLiteral = v2JSONLiteral(script) let framePrelude: String if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) { @@ -6052,7 +6064,7 @@ class TerminalController { return await __cmuxEvalInFrame(); """ - var rawResult: V2JavaScriptResult + var rawResult: BrowserJavaScriptEvaluationResult if #available(macOS 11.0, *) { rawResult = v2RunJavaScript( webView, @@ -6081,10 +6093,11 @@ class TerminalController { // a value from the isolated world that differs from the page world with no visible signal. // // The isolated world cannot see page-world JS globals (window.reactRoot set by the page's - // own scripts). For internal automation (useEval == false) that is transparent. For a - // user-supplied browser.eval (useEval == true) it matters, so we invoke - // onIsolatedWorldFallback to let browser.eval annotate the result with the content world. - if case .failure(let pageMessage) = rawResult, + // own scripts). Page-global telemetry and dialog commands therefore set requiresPageWorld + // and surface the page-world failure instead of silently reading a different window. For a + // user-supplied browser.eval, onIsolatedWorldFallback annotates the result's content world. + if !requiresPageWorld, + case .failure(let pageMessage) = rawResult, v2BrowserFailureLooksLikeCSPEvalBlock(pageMessage), #available(macOS 11.0, *) { let isolatedResult = v2RunJavaScript( @@ -6102,10 +6115,19 @@ class TerminalController { if isolatedMessage != pageMessage { rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))") } + case .timedOut: + rawResult = .timedOut } } - switch rawResult { + let resolvedResult = v2RecoverTimedOutBrowserJavaScript( + rawResult, + webView: webView, + browserPanel: browserPanel, + surfaceId: surfaceId + ) + + switch resolvedResult { case .failure(let message): return .failure(message) case .success(let value): @@ -6172,40 +6194,7 @@ class TerminalController { return first } - private func v2BrowserEnsureInitScriptsApplied(surfaceId: UUID, browserPanel: BrowserPanel) { - let scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? [] - let styles = v2BrowserInitStylesBySurface[surfaceId] ?? [] - guard !scripts.isEmpty || !styles.isEmpty else { return } - - let injector = """ - (() => { - window.__cmuxInitScriptsApplied = window.__cmuxInitScriptsApplied || { scripts: [], styles: [] }; - return true; - })() - """ - _ = v2RunBrowserJavaScript(v2MainSync { browserPanel.webView }, surfaceId: surfaceId, script: injector) - - for script in scripts { - _ = v2RunBrowserJavaScript(v2MainSync { browserPanel.webView }, surfaceId: surfaceId, script: script) - } - for css in styles { - let cssLiteral = v2JSONLiteral(css) - let styleScript = """ - (() => { - const id = 'cmux-init-style-' + btoa(unescape(encodeURIComponent(\(cssLiteral)))).replace(/=+$/g, ''); - if (document.getElementById(id)) return true; - const el = document.createElement('style'); - el.id = id; - el.textContent = String(\(cssLiteral)); - (document.head || document.documentElement || document.body).appendChild(el); - return true; - })() - """ - _ = v2RunBrowserJavaScript(v2MainSync { browserPanel.webView }, surfaceId: surfaceId, script: styleScript) - } - } - - private nonisolated func v2PNGData(from image: NSImage) -> Data? { + nonisolated func v2PNGData(from image: NSImage) -> Data? { guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } return rep.representation(using: .png, properties: [:]) @@ -6529,7 +6518,7 @@ class TerminalController { ) -> [String: Any] { let script = v2BrowserControl.notFoundDiagnosticsScript(selector: selector) - switch v2RunBrowserJavaScript(v2MainSync { browserPanel.webView }, surfaceId: surfaceId, script: script, timeout: 4.0) { + switch v2RunBrowserJavaScript(v2MainSync { browserPanel.webView }, browserPanel: browserPanel, surfaceId: surfaceId, script: script, timeout: 4.0) { case .failure(let message): return [ "selector": selector, @@ -6647,7 +6636,7 @@ class TerminalController { let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script, useEval: false) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector]) case .success(let value): @@ -6674,6 +6663,7 @@ class TerminalController { let waitTimeoutMs = max(80, (retryAttempts - attempt) * 80) guard case .met = v2WaitForBrowserCondition( ctx.webView, + browserPanel: ctx.browserPanel, surfaceId: surfaceId, conditionScript: selectorCondition, timeoutMs: waitTimeoutMs @@ -6720,6 +6710,7 @@ class TerminalController { var usedIsolatedWorld = false switch v2RunBrowserJavaScript( ctx.webView, + browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script, timeout: 10.0, @@ -6935,7 +6926,7 @@ class TerminalController { })() """ - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -7064,6 +7055,7 @@ class TerminalController { var setupResult: V2CallResult? var workspaceId: UUID? var surfaceIdOut: UUID? + var browserPanel: BrowserPanel? var webView: WKWebView? v2MainSync { @@ -7082,13 +7074,14 @@ class TerminalController { } workspaceId = context.workspaceId surfaceIdOut = context.surfaceId + browserPanel = context.browserPanel webView = context.webView } if let setupResult { return setupResult } - guard let workspaceId, let surfaceIdOut, let webView else { + guard let workspaceId, let surfaceIdOut, let browserPanel, let webView else { return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil) } @@ -7105,6 +7098,7 @@ class TerminalController { switch v2WaitForBrowserCondition( webView, + browserPanel: browserPanel, surfaceId: surfaceIdOut, conditionScript: conditionScript, timeoutMs: timeoutMs @@ -7365,7 +7359,7 @@ class TerminalController { let script = v2BrowserControl.keyboardScript(action: action, event: event) return v2BrowserWithPanelContext(params: params) { ctx in let surfaceId = ctx.surfaceId - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success: @@ -7454,7 +7448,7 @@ class TerminalController { script = "window.scrollBy({ left: \(dx), top: \(dy), behavior: 'instant' }); ({ ok: true })" } - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -7519,33 +7513,31 @@ class TerminalController { return (nil, context.workspaceId, context.surfaceId, context.browserPanel) } - if let error = resolved.error { - return error - } + if let error = resolved.error { return error } guard let workspaceId = resolved.workspaceId, let surfaceId = resolved.surfaceId, let browserPanel = resolved.browserPanel else { return .err(code: "internal_error", message: "Browser operation failed", data: nil) } - let snapshotResult: Data?? = v2AwaitCallback(timeout: 15.0) { finish in - v2MainSync { - browserPanel.captureAutomationVisibleViewportSnapshot { result in - switch result { - case .success(let image): - finish(self.v2PNGData(from: image)) - case .failure: - finish(nil) - } - } - } + guard let snapshotAttempt = v2CaptureBrowserAutomationSnapshot(browserPanel, timeout: 17.0) else { + return .err(code: "timeout", message: BrowserScreenshotError.automationTimedOut.localizedDescription, data: nil) } - - guard let snapshotResult else { - return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) - } - guard let imageData = snapshotResult else { - return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) + let imageData: Data + switch snapshotAttempt.result { + case .success(let data): + imageData = data + case .failure(let message): + return .err(code: "internal_error", message: message, data: nil) + case .timedOut: + let message = v2BrowserAutomationMessageAfterLivenessCheck( + originalMessage: BrowserScreenshotError.automationTimedOut.localizedDescription, + browserPanel: browserPanel, + surfaceId: surfaceId, + expectedWebViewIdentifier: snapshotAttempt.webViewIdentifier, + channel: .screenshot + ) + return .err(code: "timeout", message: message, data: nil) } var result: [String: Any] = [ @@ -7651,7 +7643,7 @@ class TerminalController { } let selectorLiteral = v2JSONLiteral(selector) let script = "document.querySelectorAll(\(selectorLiteral)).length" - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8214,7 +8206,7 @@ class TerminalController { let surfaceId = ctx.surfaceId let script = v2BrowserControl.findScript(finderBody: finderBody) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: ["action": actionName]) case .success(let value): @@ -8376,7 +8368,7 @@ class TerminalController { return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw]) } let script = v2BrowserControl.findFirstScript(selector: selector) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8410,7 +8402,7 @@ class TerminalController { return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw]) } let script = v2BrowserControl.findLastScript(selector: selector) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8450,7 +8442,7 @@ class TerminalController { return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw]) } let script = v2BrowserControl.findNthScript(selector: selector, index: index) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8502,7 +8494,7 @@ class TerminalController { return { ok: true }; })() """ - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8537,30 +8529,36 @@ class TerminalController { } } - private nonisolated func v2BrowserEnsureTelemetryHooks(surfaceId _: UUID, webView: WKWebView) { + private nonisolated func v2BrowserEnsureTelemetryHooks(browserPanel: BrowserPanel, surfaceId: UUID, webView: WKWebView) { let source = v2MainSync { BrowserPanel.telemetryHookBootstrapScriptSource } - _ = v2RunJavaScript( + _ = v2RunBrowserJavaScript( webView, + browserPanel: browserPanel, + surfaceId: surfaceId, script: source, timeout: 5.0, - world: .page + useEval: false, + requiresPageWorld: true ) } - private nonisolated func v2BrowserEnsureDialogHooks(webView: WKWebView) { + private nonisolated func v2BrowserEnsureDialogHooks(browserPanel: BrowserPanel, surfaceId: UUID, webView: WKWebView) { let source = v2MainSync { BrowserPanel.dialogTelemetryHookBootstrapScriptSource } - _ = v2RunJavaScript( + _ = v2RunBrowserJavaScript( webView, + browserPanel: browserPanel, + surfaceId: surfaceId, script: source, timeout: 5.0, - world: .page + useEval: false, + requiresPageWorld: true ) } private nonisolated func v2BrowserDialogRespond(params: [String: Any], accept: Bool) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - v2BrowserEnsureTelemetryHooks(surfaceId: ctx.surfaceId, webView: ctx.webView) - v2BrowserEnsureDialogHooks(webView: ctx.webView) + v2BrowserEnsureTelemetryHooks(browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, webView: ctx.webView) + v2BrowserEnsureDialogHooks(browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, webView: ctx.webView) let text = v2String(params, "text") ?? v2String(params, "prompt_text") let acceptLiteral = accept ? "true" : "false" let textLiteral = text.map(v2JSONLiteral) ?? "null" @@ -8585,7 +8583,15 @@ class TerminalController { })() """ - switch v2RunJavaScript(ctx.webView, script: script, timeout: 5.0, world: .page) { + switch v2RunBrowserJavaScript( + ctx.webView, + browserPanel: ctx.browserPanel, + surfaceId: ctx.surfaceId, + script: script, + timeout: 5.0, + useEval: false, + requiresPageWorld: true + ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9269,7 +9275,7 @@ class TerminalController { let key = v2String(params, "key") return v2BrowserWithPanelContext(params: params) { ctx in let script = v2BrowserControl.storageGetScript(storageType: storageType, key: key) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9299,7 +9305,7 @@ class TerminalController { return v2BrowserWithPanelContext(params: params) { ctx in let valueLiteral = v2JSONLiteral(v2NormalizeJSValue(value)) let script = v2BrowserControl.storageSetScript(storageType: storageType, key: key, valueLiteral: valueLiteral) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9320,7 +9326,7 @@ class TerminalController { let storageType = v2BrowserStorageType(params) return v2BrowserWithPanelContext(params: params) { ctx in let script = v2BrowserControl.storageClearScript(storageType: storageType) - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9642,7 +9648,7 @@ class TerminalController { private nonisolated func v2BrowserConsoleList(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - v2BrowserEnsureTelemetryHooks(surfaceId: ctx.surfaceId, webView: ctx.webView) + v2BrowserEnsureTelemetryHooks(browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, webView: ctx.webView) let clear = v2Bool(params, "clear") ?? false let clearLiteral = clear ? "true" : "false" let script = """ @@ -9654,7 +9660,15 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(ctx.webView, script: script, timeout: 5.0, world: .page) { + switch v2RunBrowserJavaScript( + ctx.webView, + browserPanel: ctx.browserPanel, + surfaceId: ctx.surfaceId, + script: script, + timeout: 5.0, + useEval: false, + requiresPageWorld: true + ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9676,7 +9690,7 @@ class TerminalController { private nonisolated func v2BrowserErrorsList(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - v2BrowserEnsureTelemetryHooks(surfaceId: ctx.surfaceId, webView: ctx.webView) + v2BrowserEnsureTelemetryHooks(browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, webView: ctx.webView) let clear = v2Bool(params, "clear") ?? false let clearLiteral = clear ? "true" : "false" let script = """ @@ -9688,7 +9702,15 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(ctx.webView, script: script, timeout: 5.0, world: .page) { + switch v2RunBrowserJavaScript( + ctx.webView, + browserPanel: ctx.browserPanel, + surfaceId: ctx.surfaceId, + script: script, + timeout: 5.0, + useEval: false, + requiresPageWorld: true + ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9747,7 +9769,7 @@ class TerminalController { """ let storageValue: Any - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: storageScript, timeout: 10.0) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: storageScript, timeout: 10.0) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9847,7 +9869,7 @@ class TerminalController { return true; })() """ - _ = v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) + _ = v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) } return .ok(v2BrowserPanelFields(ctx, adding: [ @@ -9863,15 +9885,10 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in let scriptsCount = v2MainSync { - var scripts = v2BrowserInitScriptsBySurface[ctx.surfaceId] ?? [] - scripts.append(script) - v2BrowserInitScriptsBySurface[ctx.surfaceId] = scripts - let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false) - ctx.webView.configuration.userContentController.addUserScript(userScript) - return scripts.count + return ctx.browserPanel.registerBrowserAutomationInitScript(userScript) } - _ = v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) + _ = v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) return .ok(v2BrowserPanelFields(ctx, adding: ["scripts": scriptsCount])) } @@ -9882,7 +9899,7 @@ class TerminalController { return .err(code: "invalid_params", message: "Missing script", data: nil) } return v2BrowserWithPanelContext(params: params) { ctx in - switch v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) { + switch v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -9907,15 +9924,10 @@ class TerminalController { """ let stylesCount = v2MainSync { - var styles = v2BrowserInitStylesBySurface[ctx.surfaceId] ?? [] - styles.append(css) - v2BrowserInitStylesBySurface[ctx.surfaceId] = styles - let userScript = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false) - ctx.webView.configuration.userContentController.addUserScript(userScript) - return styles.count + return ctx.browserPanel.registerBrowserAutomationStyleScript(userScript) } - _ = v2RunBrowserJavaScript(ctx.webView, surfaceId: ctx.surfaceId, script: source, timeout: 10.0) + _ = v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: source, timeout: 10.0) return .ok(v2BrowserPanelFields(ctx, adding: ["styles": stylesCount])) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0fb4d49420e1..9161c4f0667a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -204,6 +204,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */; }; D7032A070000000000000001 /* BrowserAuthPromptTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */; }; B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3770BA00000000000000002 /* BrowserAutomation.swift */; }; + 8054A0050000000000000005 /* BrowserAutomationProbeChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0060000000000000006 /* BrowserAutomationProbeChannel.swift */; }; + 8054B0030000000000000003 /* BrowserAutomationRecoveryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */; }; + 8054A0070000000000000007 /* BrowserAutomationSnapshotResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0080000000000000008 /* BrowserAutomationSnapshotResult.swift */; }; BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */; }; BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */; }; D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */; }; @@ -248,6 +251,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */; }; B1F0C0010000000000000001 /* BrowserInspectorFocusHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */; }; B1F0C0020000000000000001 /* BrowserInspectorFocusHandoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */; }; + 8054A0090000000000000009 /* BrowserJavaScriptEvaluationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A00A000000000000000A /* BrowserJavaScriptEvaluationResult.swift */; }; BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */; }; BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000E12 /* BrowserMediaActivityAggregationTests.swift */; }; BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */; }; @@ -272,6 +276,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D7632003A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632004A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift */; }; 7863A0030000000000000002 /* BrowserPanel+AddressBarFocusOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */; }; C65241010000000000000001 /* BrowserPanel+AppPricingRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65241010000000000000002 /* BrowserPanel+AppPricingRestore.swift */; }; + 8054A0010000000000000001 /* BrowserPanel+AutomationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0020000000000000002 /* BrowserPanel+AutomationRecovery.swift */; }; C3711E000000000000000001 /* BrowserPanel+DisplayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3711E000000000000000002 /* BrowserPanel+DisplayURL.swift */; }; D7632B11A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632B12A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift */; }; A500MX01 /* BrowserPanel+MediaPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MX00 /* BrowserPanel+MediaPlayback.swift */; }; @@ -286,6 +291,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; 7863A0040000000000000002 /* BrowserPanelView+AddressBarFocusRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0040000000000000001 /* BrowserPanelView+AddressBarFocusRequest.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + 8054BEEF0000000000000001 /* BrowserPanelViewIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054BEEF0000000000000002 /* BrowserPanelViewIdentityTests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0B1003EA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1003FA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift */; }; C67540070000000000000001 /* BrowserPDFDocumentToolbarButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */; }; @@ -344,6 +350,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A50100000000000000000022 /* BrowserWebAuthnUserDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50100000000000000000021 /* BrowserWebAuthnUserDescriptor.swift */; }; C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */; }; C0DE58990000000000000003 /* BrowserWebKitKeyDownDispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE58990000000000000001 /* BrowserWebKitKeyDownDispatch.swift */; }; + 8054B0010000000000000001 /* BrowserWebViewLifecycleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054B0020000000000000002 /* BrowserWebViewLifecycleState.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; B1F0C00A0000000000000001 /* BrowserWindowPortalInspectorLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C00A0000000000000002 /* BrowserWindowPortalInspectorLayout.swift */; }; C0DE62600000000000000001 /* BrowserWindowPortalRegistryNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE62600000000000000002 /* BrowserWindowPortalRegistryNotificationTests.swift */; }; @@ -1585,6 +1592,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; + 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */; }; C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A100000000000000000002 /* TerminalController+Capabilities.swift */; }; C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */; }; C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */; }; @@ -2175,6 +2183,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserArrowKeyForwardingTests.swift; sourceTree = ""; }; D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAuthPromptTextFormatter.swift; sourceTree = ""; }; B3770BA00000000000000002 /* BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomation.swift; sourceTree = ""; }; + 8054A0060000000000000006 /* BrowserAutomationProbeChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomationProbeChannel.swift; sourceTree = ""; }; + 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAutomationRecoveryLifecycleTests.swift; sourceTree = ""; }; + 8054A0080000000000000008 /* BrowserAutomationSnapshotResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomationSnapshotResult.swift; sourceTree = ""; }; BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserChromeMetrics.swift; sourceTree = ""; }; BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserChromeMetricsTests.swift; sourceTree = ""; }; D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserClientCertificateAuthenticationController.swift; sourceTree = ""; }; @@ -2219,6 +2230,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInsecureHTTPAlertTestSupport.swift; sourceTree = ""; }; B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/BrowserInspectorFocusHandoff.swift; sourceTree = ""; }; B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInspectorFocusHandoffTests.swift; sourceTree = ""; }; + 8054A00A000000000000000A /* BrowserJavaScriptEvaluationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserJavaScriptEvaluationResult.swift; sourceTree = ""; }; BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivity.swift; sourceTree = ""; }; BCBC0A0E0000000000000E12 /* BrowserMediaActivityAggregationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivityAggregationTests.swift; sourceTree = ""; }; BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaPlaybackAudioActivityTests.swift; sourceTree = ""; }; @@ -2243,6 +2255,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7632004A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneFileDropUploadRegressionTests.swift; sourceTree = ""; }; 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+AddressBarFocusOwnership.swift"; sourceTree = ""; }; C65241010000000000000002 /* BrowserPanel+AppPricingRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+AppPricingRestore.swift"; sourceTree = ""; }; + 8054A0020000000000000002 /* BrowserPanel+AutomationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+AutomationRecovery.swift"; sourceTree = ""; }; C3711E000000000000000002 /* BrowserPanel+DisplayURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+DisplayURL.swift"; sourceTree = ""; }; D7632B12A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+InteractiveModalHostWindow.swift"; sourceTree = ""; }; A500MX00 /* BrowserPanel+MediaPlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MediaPlayback.swift"; sourceTree = ""; }; @@ -2257,6 +2270,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; 7863A0040000000000000001 /* BrowserPanelView+AddressBarFocusRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanelView+AddressBarFocusRequest.swift"; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; + 8054BEEF0000000000000002 /* BrowserPanelViewIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelViewIdentityTests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0B1003FA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneSplitTarget.swift; sourceTree = ""; }; C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPDFDocumentToolbarButtons.swift; sourceTree = ""; }; @@ -2314,6 +2328,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50100000000000000000021 /* BrowserWebAuthnUserDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebAuthnUserDescriptor.swift; sourceTree = ""; }; C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebContentProcessTests.swift; sourceTree = ""; }; C0DE58990000000000000001 /* BrowserWebKitKeyDownDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebKitKeyDownDispatch.swift; sourceTree = ""; }; + 8054B0020000000000000002 /* BrowserWebViewLifecycleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebViewLifecycleState.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; B1F0C00A0000000000000002 /* BrowserWindowPortalInspectorLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortalInspectorLayout.swift; sourceTree = ""; }; C0DE62600000000000000002 /* BrowserWindowPortalRegistryNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortalRegistryNotificationTests.swift; sourceTree = ""; }; @@ -3477,6 +3492,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClearScreenKeepScrollbackTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; + 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomationRecovery.swift"; sourceTree = ""; }; C4A100000000000000000002 /* TerminalController+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Capabilities.swift"; sourceTree = ""; }; C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlAppFocusContext.swift"; sourceTree = ""; }; C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlBrowserPanelContext.swift"; sourceTree = ""; }; @@ -4821,7 +4837,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C67540110000000000000002 /* BrowserDownloadRecord.swift */, C67540120000000000000002 /* BrowserDownloadsToolbarButton.swift */, C67540100000000000000002 /* BrowserNavigationPopupPolicy.swift */, + 8054A0060000000000000006 /* BrowserAutomationProbeChannel.swift */, + 8054A0080000000000000008 /* BrowserAutomationSnapshotResult.swift */, + 8054A00A000000000000000A /* BrowserJavaScriptEvaluationResult.swift */, A5001412 /* BrowserPanel.swift */, + 8054A0020000000000000002 /* BrowserPanel+AutomationRecovery.swift */, + 8054B0020000000000000002 /* BrowserWebViewLifecycleState.swift */, 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */, B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */, C2035A010000000000000002 /* BrowserErrorPage.swift */, @@ -5202,6 +5223,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 590E4F5F342E5B27010ECC8D /* Cloud */, FEED0000000000000000F003 /* Feed */, C57B00040000000000000002 /* TerminalController+CustomSidebarCommands.swift */, + 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */, C57B00050000000000000002 /* CmuxExtensionSidebarSelection+CustomSidebarName.swift */, 59065015952E1B5BE5E23519 /* Mobile */, 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */, @@ -5457,6 +5479,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5008380 /* BrowserFindJavaScriptTests.swift */, C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */, B7380001B7380001B7380001 /* BrowserOmnibarSuggestionClickRoutingTests.swift */, + 8054BEEF0000000000000002 /* BrowserPanelViewIdentityTests.swift */, C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */, @@ -5491,6 +5514,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */, B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */, B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */, + 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */, B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, @@ -6400,6 +6424,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B424PNRQ0000000000000001 /* BrowserAddressBarFocusSelectionIntent.swift in Sources */, D7032A070000000000000001 /* BrowserAuthPromptTextFormatter.swift in Sources */, B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */, + 8054A0050000000000000005 /* BrowserAutomationProbeChannel.swift in Sources */, + 8054A0070000000000000007 /* BrowserAutomationSnapshotResult.swift in Sources */, BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */, D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */, D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */, @@ -6426,6 +6452,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A28B087F0000000000000015 /* BrowserImageCopyPasteboardBuilder.swift in Sources */, A28B087F0000000000000011 /* BrowserImageCopyPasteboardPayload.swift in Sources */, B1F0C0010000000000000001 /* BrowserInspectorFocusHandoff.swift in Sources */, + 8054A0090000000000000009 /* BrowserJavaScriptEvaluationResult.swift in Sources */, BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */, A500MH01 /* BrowserMediaPlaybackMessageHandler.swift in Sources */, A500MR01 /* BrowserMediaPlaybackReport.swift in Sources */, @@ -6443,6 +6470,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7632001A1B2C3D4E5F60718 /* BrowserPaneFileDropRouting.swift in Sources */, 7863A0030000000000000002 /* BrowserPanel+AddressBarFocusOwnership.swift in Sources */, C65241010000000000000001 /* BrowserPanel+AppPricingRestore.swift in Sources */, + 8054A0010000000000000001 /* BrowserPanel+AutomationRecovery.swift in Sources */, C3711E000000000000000001 /* BrowserPanel+DisplayURL.swift in Sources */, D7632B11A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift in Sources */, A500MX01 /* BrowserPanel+MediaPlayback.swift in Sources */, @@ -6501,6 +6529,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50100000000000000000020 /* BrowserWebAuthnTransportSummary.swift in Sources */, A50100000000000000000022 /* BrowserWebAuthnUserDescriptor.swift in Sources */, C0DE58990000000000000003 /* BrowserWebKitKeyDownDispatch.swift in Sources */, + 8054B0010000000000000001 /* BrowserWebViewLifecycleState.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, B1F0C00A0000000000000001 /* BrowserWindowPortalInspectorLayout.swift in Sources */, F0ACC0E00000000000000001 /* CachedAgentProcessIdentityValidator.swift in Sources */, @@ -7193,6 +7222,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */, + 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */, C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */, C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */, C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */, @@ -7643,6 +7673,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */, C0DE35870000000000000001 /* BackgroundWorkspacePrimeCoordinatorTests.swift in Sources */, D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, + 8054B0030000000000000003 /* BrowserAutomationRecoveryLifecycleTests.swift in Sources */, BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */, D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, @@ -7668,6 +7699,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7863B0010000000000000002 /* BrowserPanelFocusAndAppearanceTests.swift in Sources */, B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, + 8054BEEF0000000000000001 /* BrowserPanelViewIdentityTests.swift in Sources */, C42660040000000000000001 /* BrowserPDFPreviewActionRegressionTests.swift in Sources */, C42660050000000000000001 /* BrowserPDFPreviewActionUnitTests.swift in Sources */, C0DE78950000000000000001 /* BrowserPortalFirstRevealScrollTests.swift in Sources */, diff --git a/cmuxTests/BrowserAutomationRecoveryLifecycleTests.swift b/cmuxTests/BrowserAutomationRecoveryLifecycleTests.swift new file mode 100644 index 000000000000..abcfc8e5dfaa --- /dev/null +++ b/cmuxTests/BrowserAutomationRecoveryLifecycleTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Browser automation recovery lifecycle") +struct BrowserAutomationRecoveryLifecycleTests { + @Test("Closing a browser panel prevents automation recovery from resurrecting it") + func closedPanelCannotRecover() throws { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: try #require(URL(string: "about:blank")) + ) + let webViewIdentifier = ObjectIdentifier(panel.webView) + panel.close() + + let didRecover = panel.replaceWebViewAfterAutomationTimeout( + expectedWebViewIdentifier: webViewIdentifier, + reason: "test.closed" + ) + panel.close() + + #expect(!didRecover) + } + + @Test("An active visual capture keeps ownership of its WebView") + func activeVisualCapturePreventsRecovery() throws { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: try #require(URL(string: "about:blank")), + preloadInitialNavigationInBackground: true + ) + defer { panel.close() } + let webViewIdentifier = ObjectIdentifier(panel.webView) + panel.captureAutomationVisibleViewportSnapshot { _ in } + + #expect(!panel.canRecoverFromAutomationTimeout) + #expect(!panel.replaceWebViewAfterAutomationTimeout( + expectedWebViewIdentifier: webViewIdentifier, + reason: "test.active-capture" + )) + #expect(ObjectIdentifier(panel.webView) == webViewIdentifier) + } +} diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 6a997483efa8..009b5a6fb404 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -354,6 +354,16 @@ struct BrowserDiscardRestorePolicyCancelTests { #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) } + @Test func automationRecoveryPreservesPendingInteractivePrompt() throws { + let panel = BrowserPanel(workspaceId: UUID(), initialURL: try #require(URL(string: "about:blank")), preloadInitialNavigationInBackground: true) + defer { panel.close() } + panel.navigationDelegate?.presentAlert(NSAlert(), panel.webView, { _ in }, {}) + let webViewIdentifier = ObjectIdentifier(panel.webView) + + #expect(!panel.replaceWebViewAfterAutomationTimeout(expectedWebViewIdentifier: webViewIdentifier, reason: "test")) + #expect(ObjectIdentifier(panel.webView) == webViewIdentifier) + } + @Test func failedInsecureHTTPExternalOpenDoesNotReportTerminalRestore() throws { let url = try #require(URL(string: "http://example.com/cmux-issue-7504-open-failure")) let panel = BrowserPanel( diff --git a/cmuxTests/BrowserPanelViewIdentityTests.swift b/cmuxTests/BrowserPanelViewIdentityTests.swift new file mode 100644 index 000000000000..b07aaa7ac800 --- /dev/null +++ b/cmuxTests/BrowserPanelViewIdentityTests.swift @@ -0,0 +1,129 @@ +import AppKit +import Bonsplit +import CmuxAppKitSupportUI +import Observation +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct BrowserPanelViewIdentityTests { + @Test func replacingBrowserPanelClearsUncommittedOmnibarDraft() throws { + let workspaceID = UUID() + let firstPanel = BrowserPanel(workspaceId: workspaceID) + let secondPanel = BrowserPanel(workspaceId: workspaceID) + let model = BrowserPanelReplacementModel(panel: firstPanel) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 500), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let hostingView = NSHostingView( + rootView: BrowserPanelReplacementHarness(model: model, paneID: PaneID()) + ) + hostingView.frame = window.contentView?.bounds ?? .zero + hostingView.autoresizingMask = [.width, .height] + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + + defer { + window.orderOut(nil) + window.contentView = nil + firstPanel.close() + secondPanel.close() + } + + let firstField = try #require(waitForOmnibarField(panelID: firstPanel.id, in: window)) + firstField.stringValue = "stale search" + let firstCoordinator = try #require( + firstField.delegate as? OmnibarTextFieldRepresentable.Coordinator + ) + firstCoordinator.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: firstField) + ) + render(window) + #expect(firstField.stringValue == "stale search") + + model.panel = secondPanel + + let secondField = try #require(waitForOmnibarField(panelID: secondPanel.id, in: window)) + #expect( + secondField.stringValue.isEmpty, + "A new browser panel must not inherit the previous panel's uncommitted omnibar draft." + ) + } + + private func waitForOmnibarField( + panelID: UUID, + in window: NSWindow, + timeout: TimeInterval = 1 + ) -> OmnibarNativeTextField? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + render(window) + if let field = BrowserOmnibarNativeFieldRegistry.shared.field(for: panelID, in: window) { + return field + } + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } while Date() < deadline + return nil + } + + private func render(_ window: NSWindow) { + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + } +} + +@MainActor +@Observable +private final class BrowserPanelReplacementModel { + var panel: BrowserPanel + + init(panel: BrowserPanel) { + self.panel = panel + } +} + +@MainActor +private struct BrowserPanelReplacementHarness: View { + let model: BrowserPanelReplacementModel + let paneID: PaneID + + var body: some View { + PanelContentView( + panel: model.panel, + workspaceId: model.panel.workspaceId, + paneId: paneID, + isFocused: true, + isSelectedInPane: true, + isVisibleInUI: true, + portalPriority: 1, + isSplit: false, + appearance: PanelAppearance( + backgroundColor: .windowBackgroundColor, + foregroundColor: .labelColor, + dividerColor: .clear, + unfocusedOverlayNSColor: .clear, + unfocusedOverlayOpacity: 0, + usesClearContentBackground: false + ), + windowAppearance: .rightSidebarPanelViewTestDefault, + customSidebarTabManager: nil, + hasUnreadNotification: false, + terminalAgentContext: "", + paneOwnershipOverride: true, + onFocus: {}, + onRequestPanelFocus: {}, + onResumeAgentHibernation: {}, + onAutoResumeAgentHibernation: {}, + onTriggerFlash: {} + ) + } +}