diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index b856ea51738b..0ad93c499efe 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -428,20 +428,8 @@ extension CMUXCLI { // environments, and for a stale socket node left by an exited app: the // ambient invocation must succeed, otherwise the pinned chain runs. // https://github.com/manaflow-ai/cmux/issues/5473 - let ambientGuard: String - let ambientInvocation: String - if def.name == "grok" { - // Grok validates interpolated CMUX variables as required hook - // environment. Optional lookups preserve ambient-first routing - // when present and allow the pinned fallback when Grok strips them. - let socket = "\"$(printenv CMUX_SOCKET_PATH || true)\"" - let executable = "\"$(printenv CMUX_BUNDLED_CLI_PATH || true)\"" - ambientGuard = "[ -n \(socket) ] && [ -S \(socket) ] && [ -f \(executable) ] && [ -x \(executable) ]" - ambientInvocation = "\(pinnedHookEnvironmentPrefix(routedArguments: routedArguments))\(executable) --socket \(socket) \(routedArguments)" - } else { - ambientGuard = pinnedHookAmbientDispatchGuard - ambientInvocation = pinnedHookAmbientInvocation(routedArguments: routedArguments) - } + let ambientGuard = pinnedHookAmbientDispatchGuard + let ambientInvocation = pinnedHookAmbientInvocation(routedArguments: routedArguments) let dispatch: String if let cliPath = pinnedAgentHookCLIPath() { let quotedCLIPath = shellSingleQuote(cliPath) diff --git a/CLI/CMUXCLI+SSHStartupScripts.swift b/CLI/CMUXCLI+SSHStartupScripts.swift index 31a87d76d973..0281cbfb398e 100644 --- a/CLI/CMUXCLI+SSHStartupScripts.swift +++ b/CLI/CMUXCLI+SSHStartupScripts.swift @@ -130,7 +130,13 @@ extension CMUXCLI { " if {[string length $cmux_buffer] > 0} { send_user -- $cmux_buffer }", " cmux_relay_session", " }", - " eof { set status [wait]; exit [lindex $status 3] }", + // A session that authenticates and ends inside this window still + // owes the user its output: flush what expect buffered before exiting. + " eof {", + " catch { send_user -- $expect_out(buffer) }", + " set status [wait]", + " exit [lindex $status 3]", + " }", " }", "}", "expect {", @@ -217,7 +223,13 @@ extension CMUXCLI { " if {[string length $cmux_buffer] > 0} { send_user -- $cmux_buffer }", " cmux_relay_session", " }", - " eof { set status [wait]; exit [lindex $status 3] }", + // A session that authenticates and ends inside this window still + // owes the user its output: flush what expect buffered before exiting. + " eof {", + " catch { send_user -- $expect_out(buffer) }", + " set status [wait]", + " exit [lindex $status 3]", + " }", " }", "}", "expect {", diff --git a/CLI/CMUXCLI+VMSCP.swift b/CLI/CMUXCLI+VMSCP.swift index 02cde0328ac3..17fb412d2ef9 100644 --- a/CLI/CMUXCLI+VMSCP.swift +++ b/CLI/CMUXCLI+VMSCP.swift @@ -73,6 +73,9 @@ extension CMUXCLI { "-F", "/dev/null", "-o", "StrictHostKeyChecking=yes", "-o", "HostKeyAlgorithms=ssh-ed25519", "-o", "HostKeyAlias=cmux-scp", + // Keep host-key failures actionable while avoiding OpenSSH's + // multi-page warning banner filling a PTY-backed stderr pipe. + "-o", "LogLevel=ERROR", "-o", "UserKnownHostsFile=" + directory.appendingPathComponent("known_hosts").path.replacingOccurrences(of: "%", with: "%%"), "-o", "GlobalKnownHostsFile=/dev/null", "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2e58c710d4ea..78de43ac076d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -35979,19 +35979,18 @@ export default CMUXSessionRestore; // this narrow transcript check for pre-ledger launches so stale // prompt-depth records cannot strand an older session; it is never // part of the modern child-work decision. + let activePromptTurnStackForStop = mapped?.activePromptTurnIds? + .compactMap({ normalizedHookValue($0) }) ?? [] + let activePromptTurnIdsForStop = activePromptTurnStackForStop.isEmpty + ? normalizedHookValue(mapped?.activePromptTurnId).map { [$0] } ?? [] + : activePromptTurnStackForStop let terminalActivePromptTurnIdsForStop: Set if !relayOrigin, !staleIdleStopHasNewerRunningSession, def.name == "codex", codexLifecycle?.usesLegacyIdentity == true, let incomingTurnId = normalizedHookValue(input.turnId) { - let activePromptTurnStack = mapped?.activePromptTurnIds? - .compactMap({ normalizedHookValue($0) }) ?? [] - let activePromptTurnId = activePromptTurnStack.last ?? normalizedHookValue(mapped?.activePromptTurnId) - let activeTurnIds = activePromptTurnStack.isEmpty - ? activePromptTurnId.map { [$0] } ?? [] - : activePromptTurnStack - let activeTurnIdsToCheck = activeTurnIds.filter { $0 != incomingTurnId } + let activeTurnIdsToCheck = activePromptTurnIdsForStop.filter { $0 != incomingTurnId } if !activeTurnIdsToCheck.isEmpty, let transcriptPath = normalizedHookValue(localTranscriptPath(mapped: mapped)) ?? findCodexTranscriptPath(sessionId: sessionId, env: env) { @@ -36133,6 +36132,27 @@ export default CMUXSessionRestore; } } + if def.name == "codex", codexLifecycle?.usesLegacyIdentity == true, + !suppressCompletionNotification { + for priorTurnId in activePromptTurnIdsForStop + where terminalActivePromptTurnIdsForStop.contains(priorTurnId) { + emitAgentJournalEvent( + client: client, + kind: .idleObserved, + source: def.name, + agentKey: def.statusKey, + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + nativeEvent: "transcript-terminal", + attention: AgentAttentionContext(turnIdentity: priorTurnId), + occurredAtMs: Self.semanticOccurredAtMs(input.rawObject), + store: store, + telemetry: telemetry + ) + } + } + // The journal records the turn boundary unconditionally: the // reducer's per-session fold handles stale sessions (a newer // running session outranks this one) and subagent tagging keeps diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 58a835905207..e7a383ab626c 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -125,8 +125,11 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { // surfaces. Keep them on the worker lane so markdown/file-preview panes // reach TerminalController's mobile.panel.artifact.* dispatcher instead // of the main-actor switch returning method_not_found. + // `mobile.panel.artifact.fetch` is deliberately absent: it needs the + // authenticated mobile RPC execution context, so the local control + // socket answers method_not_found instead of bypassing + // artifact-transfer authorization (the worker switch has no case for it). "mobile.panel.artifact.stat", - "mobile.panel.artifact.fetch", "mobile.panel.artifact.thumbnail", "system.top", "system.memory", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 6918ca4a6c07..3905e7409f65 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -56,8 +56,7 @@ struct ControlCommandExecutionPolicyTests { "vault.sessions", "vault.search", "vault.checkpoints", "vault.checkpoint", "vault.fork", "mobile.compatible_tags.get", "mobile.compatible_tags.set", - "mobile.panel.artifact.stat", "mobile.panel.artifact.fetch", - "mobile.panel.artifact.thumbnail", + "mobile.panel.artifact.stat", "mobile.panel.artifact.thumbnail", // JavaScript-evaluating browser methods block on page JS and must // not hold the main actor (see socketWorkerMethods rationale). "browser.eval", "browser.wait", "browser.snapshot", "browser.click", @@ -89,6 +88,9 @@ struct ControlCommandExecutionPolicyTests { "workspace.create", "browser.url.get", "browser.open_split", "browser.get.title", "browser.frame.main", "mobile.terminal.create", "mobile.task.attachment.upload", + // Artifact fetch needs the authenticated mobile execution context; + // the local socket must answer method_not_found, not serve bytes. + "mobile.panel.artifact.fetch", "vmx.create", "", // Focus-intent verbs stay on the main lane until the mutations // tranche decides them deliberately. @@ -174,7 +176,7 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "system.top") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "mobile.task.models.list") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.stat") == .socketWorker(mainThreadCallable: false)) - #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.fetch") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.fetch") == .mainActor) #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.thumbnail") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) } diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html index ec4237a76621..3164cf66f551 100644 --- a/Resources/markdown-viewer/shell.html +++ b/Resources/markdown-viewer/shell.html @@ -1188,6 +1188,13 @@ return Math.max(0, Math.min(markdownMaxScrollY(), y)); } + // `setMarkdownScrollY` is the viewer's only programmatic scroll, so the + // position it last wrote is the position the viewer is in unless something + // else moved it: the reader, a find match, an anchor jump. Recording it lets + // the scroll listener below tell those apart from a reflow, which moves + // content under a stationary viewer without scrolling it. + var markdownScrollYWeWrote = null; + function setMarkdownScrollY(y) { var targetY = clampScrollY(y); var scroller = markdownScroller(); @@ -1201,6 +1208,7 @@ } } finally { root.style.scrollBehavior = previousBehavior; + markdownScrollYWeWrote = markdownScrollY(); } } @@ -1233,8 +1241,55 @@ }; } + // The restore runs once synchronously, then again on the next two animation + // frames so late layout (images, web fonts, Mermaid) that moves the anchor is + // still corrected. Those follow-up passes must keep making that correction, + // but must never fight a scroll that happened after the content update: a + // queued pass that fires once the reader -- or a find match, or an anchor + // jump -- has already moved the viewer would yank it back to the pre-update + // position. + // + // The two cases are told apart causally, not by comparing positions. A reflow + // moves content under a stationary viewer and fires no scroll event, so the + // correction still runs -- that is the whole reason the frames exist. A real + // scroll fires one, and since `setMarkdownScrollY` is the viewer's only + // programmatic scroll, any scroll that leaves the viewer somewhere other than + // the position we last wrote came from someone else, and this transaction + // stands down. A new content update also cancels the frames the previous one + // queued. + // + // The synchronous pass is the authoritative one. Nothing in the viewer, and + // nothing in its tests, may treat an animation frame as the completion signal + // for a render: a window that is not on an active display never gets one. + var markdownScrollRestoreFrame = 0; + var markdownScrollRestoreAbandoned = true; + var markdownScrollListenerInstalled = false; + + function installMarkdownScrollListenerIfNeeded() { + if (markdownScrollListenerInstalled) { return; } + markdownScrollListenerInstalled = true; + window.addEventListener('scroll', function() { + if (markdownScrollRestoreAbandoned) { return; } + if (markdownScrollYWeWrote === null) { return; } + if (Math.abs(markdownScrollY() - markdownScrollYWeWrote) > 1) { + cancelPendingMarkdownScrollRestore(); + } + }, { passive: true }); + } + + function cancelPendingMarkdownScrollRestore() { + if (markdownScrollRestoreFrame) { + window.cancelAnimationFrame(markdownScrollRestoreFrame); + } + markdownScrollRestoreFrame = 0; + markdownScrollRestoreAbandoned = true; + } + function restoreMarkdownScrollState(state) { + cancelPendingMarkdownScrollRestore(); if (!state) { return; } + installMarkdownScrollListenerIfNeeded(); + markdownScrollRestoreAbandoned = false; function apply() { var targetY; if (state.nearTop) { @@ -1252,10 +1307,18 @@ } setMarkdownScrollY(targetY); } - apply(); - window.requestAnimationFrame(function() { + function reapplyUnlessAbandoned() { + markdownScrollRestoreFrame = 0; + if (markdownScrollRestoreAbandoned) { return false; } apply(); - window.requestAnimationFrame(apply); + return true; + } + apply(); + markdownScrollRestoreFrame = window.requestAnimationFrame(function() { + if (!reapplyUnlessAbandoned()) { return; } + markdownScrollRestoreFrame = window.requestAnimationFrame(function() { + reapplyUnlessAbandoned(); + }); }); } diff --git a/Sources/AppDelegate+NewCloudWorkspace.swift b/Sources/AppDelegate+NewCloudWorkspace.swift index cbe16ef4f2de..4ee3b3827289 100644 --- a/Sources/AppDelegate+NewCloudWorkspace.swift +++ b/Sources/AppDelegate+NewCloudWorkspace.swift @@ -65,11 +65,26 @@ extension AppDelegate { } } + /// The window a finished Cloud creation is allowed to navigate: the window + /// that started the creation, and only while that window is still key. + /// A creation that lands while the user is working in another window must + /// not pull them away from it. + /// + /// This asks the window itself, the same way every other focus-gated path + /// in the app does, rather than comparing against `NSApp.keyWindow`. The + /// two agree in the running app, and the window-scoped question is the one + /// this rule is actually about. + func cloudWorkspaceCreationFocusWindow(windowID: UUID) -> NSWindow? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowID }), + let window = resolvedWindow(for: context), + window.isKeyWindow else { return nil } + return window + } + private func focusCreatedCloudWorkspace(_ workspaceID: UUID, manager: TabManager?, revision: UInt64, windowID: UUID) { guard let manager, manager.cloudWorkspaceSelection.revision == revision, tabManagerFor(windowId: windowID) === manager, - let window = mainWindowContexts.values.first(where: { $0.windowId == windowID }).flatMap({ resolvedWindow(for: $0) }), - window === NSApp.keyWindow, + cloudWorkspaceCreationFocusWindow(windowID: windowID) != nil, let workspace = manager.workspacesById[workspaceID] else { return } manager.selectWorkspace(workspace) } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8cb65e8604ca..d77294222c41 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9719,8 +9719,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // An addressable duplicate window can fail closed during reindexing; // retain the validated active owner until routing resolves rather than // pruning it merely because its cached AppKit identity is transiently - // absent. Windowless app shortcuts still prune as before. - if (event == nil || eventContext != nil) && pruneWindowlessActiveMainWindowContext() { + // absent. That race only exists when the event names a window, so gate + // the deferral on the event's addressability: an event that carries no + // window at all (windowNumber 0 responder-chain shortcuts) has no + // reindexing candidate to protect, and must still prune as before. + let eventDefersActiveContextPrune = event.map { event in + eventContext == nil && shortcutEventHasAddressableWindow(event) + } ?? false + if !eventDefersActiveContextPrune && pruneWindowlessActiveMainWindowContext() { #if DEBUG logWorkspaceCreationRouting( phase: "choose", diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 27203a53145f..146265ae9dc4 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -578,7 +578,8 @@ enum CloudTreeNodeBuilder { let projectionIndex = LocalProjectionIndex(snapshot: snapshot, unreadTerminalIDs: unreadTerminalIDs) let resourceNodeBuilder = CloudTreeMachineResourceNodeBuilder() var identities = adoptedOperationIDs - for operation in pendingCreates where !operation.request.isBaseSetup { + for operation in pendingCreates where !operation.request.isBaseSetup && + (operation.isRunning || operation.isReconciling) { if let id = operation.createdMachineID ?? operation.reconcilingMachineID, identities[id] == nil { identities[id] = operation.id } @@ -600,9 +601,8 @@ enum CloudTreeNodeBuilder { nodes.append(CloudTreeNode(id: nodeID(pendingCreate: operation.id), kind: .pendingMachine(operation))) } let infoByMachine = Dictionary(snapshot.machines.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - let failedIDs = Set(pendingCreates.filter { !$0.request.isBaseSetup && $0.failureOutput != nil }.compactMap(\.createdMachineID)) - var seen = failedIDs - for machine in machines where !failedIDs.contains(machine.id) { + var seen = Set() + for machine in machines { seen.insert(machine.id) let info = infoByMachine[.cloud(machine.id)] let stableID = identities[machine.id].map { nodeID(pendingCreate: $0) } diff --git a/Sources/Cloud/MachineCreateCoordinator.swift b/Sources/Cloud/MachineCreateCoordinator.swift index 8b973e4a3303..fe8028db7f6e 100644 --- a/Sources/Cloud/MachineCreateCoordinator.swift +++ b/Sources/Cloud/MachineCreateCoordinator.swift @@ -34,7 +34,10 @@ final class MachineCreateCoordinator { cancelCreatedMachine: { CloudVMActionLauncher.shared.destroyMachineBestEffort($0) }, cancelOperation: { operation in guard let workspaceID = operation.request.presentationWorkspaceID else { return } - NewMachineSheetPresenter.closeReservedWorkspace(workspaceID) + NewMachineSheetPresenter.closeReservedWorkspace( + workspaceID, + machineID: operation.createdMachineID ?? operation.reconcilingMachineID + ) } ) static let didChangeNotification = Notification.Name("cmux.machineCreate.didChange") diff --git a/Sources/Cloud/NewMachineSheetPresenter.swift b/Sources/Cloud/NewMachineSheetPresenter.swift index f282d63bee00..1ab5c461d115 100644 --- a/Sources/Cloud/NewMachineSheetPresenter.swift +++ b/Sources/Cloud/NewMachineSheetPresenter.swift @@ -58,14 +58,34 @@ final class NewMachineSheetPresenter: NSObject, NewMachineSheetPresenting { return request.targetingReservedWorkspace(workspaceID) } - /// Removes a reservation after launch refusal or explicit dismissal. A - /// normal window always has another workspace; if this was the final tab, - /// the existing close policy keeps the window alive and the caller can - /// still inspect the inline failure state. - static func closeReservedWorkspace(_ workspaceID: UUID) { + /// Removes only the unadopted creating card. User-added panes and an already + /// attached terminal are no longer a disposable create presentation. + static func closeReservedWorkspace(_ workspaceID: UUID, machineID: String? = nil) { guard let appDelegate = AppDelegate.shared, let tabManager = appDelegate.tabManagerFor(tabId: workspaceID), let workspace = tabManager.tabs.first(where: { $0.id == workspaceID }) else { return } + let loading = workspace.panels.values.compactMap { $0 as? CloudVMLoadingPanel } + guard !loading.isEmpty else { return } + let ownsBinding = workspace.cloudVMBinding?.vmID == nil + || workspace.cloudVMBinding?.vmID == machineID + guard ownsBinding else { return } + if loading.count < workspace.panels.count { + // A cancelled create may destroy its provider machine after this + // callback. Detach the preserved user content from that machine + // before the shared destroy cleanup scans bound workspaces. + workspace.cloudVMBinding = nil + workspace.withClosedPanelHistorySuppressed { + for panel in loading { _ = workspace.closePanel(panel.id, force: true) } + } + return + } + // Closing the last workspace normally leaves it intact. A cancelled + // create has no remaining operation to render, so provide a normal local + // anchor before removing its card, without activating the window. + if tabManager.tabs.count == 1 { + guard tabManager.addWorkspaceIfActive(inheritWorkingDirectory: false, select: false, + eagerLoadTerminal: false, autoWelcomeIfNeeded: false) != nil else { return } + } tabManager.closeWorkspace(workspace, recordHistory: false) } diff --git a/Sources/Cloud/PortForward/CloudPortRoutePlan.swift b/Sources/Cloud/PortForward/CloudPortRoutePlan.swift index 8da088ea0a8e..b1f40df385d1 100644 --- a/Sources/Cloud/PortForward/CloudPortRoutePlan.swift +++ b/Sources/Cloud/PortForward/CloudPortRoutePlan.swift @@ -10,7 +10,7 @@ enum CloudPortRoutePlan: Equatable, Sendable { static func plan(resource: SurfaceResource, privateAddress: String?) -> CloudPortRoutePlan { let desktop = resource.kind == .display - guard let port = resource.id.forwardedPort ?? resource.port ?? (desktop ? CmuxTuiSnapshotParser.desktopPort : nil), + guard let port = resource.id.forwardedPort ?? resource.port, (1...65_535).contains(port) else { return .unsupported(String(format: String(localized: "cloudTree.port.noPort", defaultValue: "%@ has no port to open."), resource.id.rawValue)) } @@ -18,7 +18,7 @@ enum CloudPortRoutePlan: Equatable, Sendable { return .unsupported(String(format: String(localized: "cloudTree.port.noPrivateAddress", defaultValue: "%@ has no private network address yet; refresh the machine list and retry."), resource.machine.rawValue)) } let raw = resource.url ?? (desktop - ? CmuxTuiSurfaceProvider.privateDesktopURL(privateAddress: address) + ? CmuxTuiSurfaceProvider.privateDesktopURL(privateAddress: address, port: port) : CmuxInternalHostnames().directPortURL(privateAddress: address, port: port)) guard let url = privateURL(raw, address: address) else { return .unsupported(String(localized: "cloud.portAccess.invalidURL", defaultValue: "This port does not have a valid HTTP or HTTPS address.")) diff --git a/Sources/CloudTerminalReconnectOverlayView.swift b/Sources/CloudTerminalReconnectOverlayView.swift index 7a60aa871673..3e7cf4faa204 100644 --- a/Sources/CloudTerminalReconnectOverlayView.swift +++ b/Sources/CloudTerminalReconnectOverlayView.swift @@ -5,9 +5,18 @@ import SwiftUI /// same responsive card, so a narrow terminal never gets a 260pt minimum dialog. @MainActor final class CloudTerminalReconnectOverlayView: NSView { + /// Identifies the card host inside the overlay. The card itself is SwiftUI, + /// so its AppKit class is an implementation detail; this identifier is the + /// stable handle for locating the laid-out card. + static let cardAccessibilityIdentifier = "CloudTerminalReconnectCard" + var onReconnect: (() -> Void)? var onDismiss: (() -> Void)? private(set) var currentPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? + /// The action wired to the card's Retry control, or nil when the current + /// presentation offers no retry. This is the value handed to the card, so + /// it is the same path the control invokes. + private(set) var reconnectAction: (() -> Void)? private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) private var renderedWidth: CGFloat = 0 private var needsContentUpdate = true @@ -18,6 +27,7 @@ final class CloudTerminalReconnectOverlayView: NSView { hostingView.sizingOptions = [.intrinsicContentSize] hostingView.wantsLayer = true hostingView.layer?.backgroundColor = NSColor.clear.cgColor + hostingView.setAccessibilityIdentifier(Self.cardAccessibilityIdentifier) addSubview(hostingView) } @@ -33,7 +43,7 @@ final class CloudTerminalReconnectOverlayView: NSView { hostingView.rootView = AnyView( Content( presentation: presentation, - onReconnect: { [weak self] in self?.onReconnect?() }, + onReconnect: reconnectAction, onDismiss: { [weak self] in self?.onDismiss?() } ) .frame(width: width) @@ -59,13 +69,16 @@ final class CloudTerminalReconnectOverlayView: NSView { func apply(_ presentation: CloudTerminalReconnectOverlayPolicy.Presentation) { guard currentPresentation != presentation else { return } currentPresentation = presentation + reconnectAction = presentation.showsReconnectButton + ? { [weak self] in self?.onReconnect?() } + : nil needsContentUpdate = true needsLayout = true } private struct Content: View { let presentation: CloudTerminalReconnectOverlayPolicy.Presentation - let onReconnect: () -> Void + let onReconnect: (() -> Void)? let onDismiss: () -> Void #if DEBUG @AppStorage("cloudPaneFailurePrototypeStyle") private var prototypeStyle = "compact-bordered" @@ -87,7 +100,7 @@ final class CloudTerminalReconnectOverlayView: NSView { detail: presentation.detail, copyableText: presentation.copyableError, style: style, - onRetry: presentation.showsReconnectButton ? onReconnect : nil, + onRetry: onReconnect, onDismiss: onDismiss ) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7ff4d04783f4..2574ce3531fb 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2693,7 +2693,12 @@ struct ContentView: View { var body: some View { #if DEBUG - let _ = { minimalModeInvalidationProbe.contentViewBody?() }() + let _ = { + if minimalModeInvalidationProbe.shouldTraceBodyChanges?() == true { + Self._printChanges() + } + minimalModeInvalidationProbe.contentViewBody?() + }() #endif let appearance = windowAppearanceSnapshot var view = AnyView( @@ -11743,7 +11748,13 @@ struct VerticalTabsSidebar: View, Equatable { var body: some View { #if DEBUG - let _ = { minimalModeInvalidationProbe.verticalTabsSidebarBody?() }() + let _ = { + if minimalModeInvalidationProbe.shouldTraceBodyChanges?() == true + || sidebarLazyContractProbe.shouldTraceBodyChanges?() == true { + Self._printChanges() + } + minimalModeInvalidationProbe.verticalTabsSidebarBody?() + }() #endif let signpost = SidebarProfilingSignposts.begin("vertical-sidebar-body", "workspaces=\(tabManager.tabs.count) selected=\(sidebarShortTabId(tabManager.selectedTabId))") // Retain the native table identity while hidden without continuing the diff --git a/Sources/Debug/MinimalModeInvalidationProbe.swift b/Sources/Debug/MinimalModeInvalidationProbe.swift index 45b97dbdc562..81f64880d94b 100644 --- a/Sources/Debug/MinimalModeInvalidationProbe.swift +++ b/Sources/Debug/MinimalModeInvalidationProbe.swift @@ -2,6 +2,8 @@ import SwiftUI #if DEBUG struct MinimalModeInvalidationProbe { + /// Armed only during a test's measured transition; never observes app state. + var shouldTraceBodyChanges: (() -> Bool)? var contentViewBody: (() -> Void)? var workspaceContentBody: (() -> Void)? var verticalTabsSidebarBody: (() -> Void)? diff --git a/Sources/Debug/SidebarLazyContractProbe.swift b/Sources/Debug/SidebarLazyContractProbe.swift index ef3506ae63b2..1c74d5cfa910 100644 --- a/Sources/Debug/SidebarLazyContractProbe.swift +++ b/Sources/Debug/SidebarLazyContractProbe.swift @@ -8,6 +8,8 @@ import SwiftUI /// /// Same pattern as `MinimalModeInvalidationProbe`; compiled out of Release. struct SidebarLazyContractProbe { + /// Armed only during a test's measured transition; never observes app state. + var shouldTraceBodyChanges: (() -> Bool)? var workspaceRowBody: (() -> Void)? var workspaceRowBodyEnd: (() -> Void)? var groupHeaderRowBody: (() -> Void)? diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 395bbcc32fa8..571f2a5466ab 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1039,28 +1039,18 @@ final class MobileHostService { pairingURLScheme: CmxPairingURLScheme? = CmxPairingURLSchemeResolver().resolved ) async throws -> [String: Any] { - let routes = MobileHostPublicStatusCache.snapshot() - let filteredRoutes = try Self.filteredRoutes( - routes, + let subject = try Self.attachTicketSubject( + publishedStatus: MobileHostPublicStatusCache.publishedStatus(), routeID: routeID, - routeKind: routeKind + routeKind: routeKind, + target: target ) - let selectedRoutes = try target.selectRoutes(from: filteredRoutes) - let deviceID: String - if selectedRoutes.contains(where: { $0.kind == .iroh }) { - guard let publishedID = MobileHostPublicStatusCache.currentV2DeviceID() else { - throw MobileAttachTicketStoreError.routeUnavailable - } - deviceID = publishedID - } else { - deviceID = MobileHostIdentity.deviceID() - } let ticket = try ticketStore.createTicket( workspaceID: workspaceID, terminalID: terminalID, - routes: selectedRoutes, + routes: subject.routes, ttl: ttl, - macDeviceID: deviceID, + macDeviceID: subject.deviceID, macUserEmail: await currentAuthenticatedLocalUserEmail(), macUserID: await currentAuthenticatedLocalUserID(), macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, @@ -1075,6 +1065,35 @@ final class MobileHostService { ) } + /// What a ticket for `target` describes: the routes the peer may dial and + /// the Mac identity they belong to, resolved from a single publication. + /// + /// Routes and identity must come from the *same* publication. An Iroh + /// route is dialed through the v2 directory, so a ticket that names one + /// before the installation identity has been published would send the + /// phone to an identity that does not exist yet; that case is refused + /// rather than falling back to the legacy per-install identity. + static func attachTicketSubject( + publishedStatus: MobileHostPublicStatusCache.PublishedStatus, + routeID: String?, + routeKind: String?, + target: MobileAttachTarget? + ) throws -> (routes: [CmxAttachRoute], deviceID: String) { + let narrowedRoutes = try Self.filteredRoutes( + publishedStatus.routes, + routeID: routeID, + routeKind: routeKind + ) + let selectedRoutes = try target.selectRoutes(from: narrowedRoutes) + guard selectedRoutes.contains(where: { $0.kind == .iroh }) else { + return (selectedRoutes, MobileHostIdentity.deviceID()) + } + guard let publishedID = publishedStatus.v2DeviceID else { + throw MobileAttachTicketStoreError.routeUnavailable + } + return (selectedRoutes, publishedID) + } + private static func filteredRoutes( _ routes: [CmxAttachRoute], routeID: String?, diff --git a/Sources/Mobile/MobileHostTransportAuthorization.swift b/Sources/Mobile/MobileHostTransportAuthorization.swift index f86a23af4120..679ae3702d93 100644 --- a/Sources/Mobile/MobileHostTransportAuthorization.swift +++ b/Sources/Mobile/MobileHostTransportAuthorization.swift @@ -318,6 +318,29 @@ enum MobileHostPublicStatusCache { return mergedRoutesLocked() } + /// One publication of this Mac: the routes a peer may dial plus the v2 + /// installation identity those routes belong to. + /// + /// Ticket minting needs both halves of the *same* publication. Reading + /// ``snapshot()`` and ``currentV2DeviceID()`` separately can straddle a + /// concurrent republish or teardown (the runtime calls ``removeAll()`` on + /// every reconcile), which would bind fresh routes to a retired identity. + struct PublishedStatus: Sendable { + var routes: [CmxAttachRoute] + var v2DeviceID: String? + + init(routes: [CmxAttachRoute], v2DeviceID: String?) { + self.routes = routes + self.v2DeviceID = v2DeviceID + } + } + + static func publishedStatus() -> PublishedStatus { + lock.lock() + defer { lock.unlock() } + return PublishedStatus(routes: mergedRoutesLocked(), v2DeviceID: v2DeviceID) + } + static func hasIrohRoute() -> Bool { lock.lock() defer { lock.unlock() } diff --git a/Sources/SharedLiveAgentIndex.swift b/Sources/SharedLiveAgentIndex.swift index 12cb9613c5b9..64b77305156f 100644 --- a/Sources/SharedLiveAgentIndex.swift +++ b/Sources/SharedLiveAgentIndex.swift @@ -50,6 +50,10 @@ final class SharedLiveAgentIndex { id: UUID, continuation: CheckedContinuation ) + private typealias ForkSupportValidationWaiter = ( + id: UUID, + continuation: CheckedContinuation + ) private typealias ForkValidationRequestCompletionWaiter = ( id: UUID, continuation: CheckedContinuation @@ -101,7 +105,7 @@ final class SharedLiveAgentIndex { private var validatedForkPanels = Set() private var validatedMissingForkPanels: [RestorableAgentSessionIndex.PanelKey: Date] = [:] private var activeForkSupportValidationKeys = Set() - private var activeForkSupportValidationWaiters: [ForkProbeKey: [CheckedContinuation]] = [:] + private var activeForkSupportValidationWaiters: [ForkProbeKey: [ForkSupportValidationWaiter]] = [:] private var activeForkSupportValidationIdentityWaiters: [ForkValidationWaitKey: [ForkValidationIdentityWaiter]] = [:] private var forkValidationRequestCompletionWaiters: [UUID: [ForkValidationRequestCompletionWaiter]] = [:] private var deferredForkAvailabilityRefreshAfterActiveValidation = false @@ -240,7 +244,7 @@ final class SharedLiveAgentIndex { } for waiters in activeForkSupportValidationWaiters.values { for waiter in waiters { - waiter.resume() + waiter.continuation.resume() } } for waiters in activeForkSupportValidationIdentityWaiters.values { @@ -1206,16 +1210,65 @@ final class SharedLiveAgentIndex { } } - private func waitForActiveForkSupportValidation(_ probeKey: ForkProbeKey) async { - await withCheckedContinuation { continuation in - guard activeForkSupportValidationKeys.contains(probeKey) else { - continuation.resume() - return + /// Waits for the active probe that holds `probeKey`, while the caller's own + /// requests sit in the pending queue. Cancellation drops those requests + /// immediately: the active probe's completion restarts the single-flight + /// refresh for whatever is still pending, and a cancelled caller's request + /// must not be among it, or its fallback is probed and replaces the + /// surviving request's validation. + private func waitForActiveForkSupportValidation( + _ probeKey: ForkProbeKey, + pendingRequestIDsToRemoveOnCancellation: [ForkProbeKey: Set] + ) async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard activeForkSupportValidationKeys.contains(probeKey) else { + continuation.resume() + return + } + activeForkSupportValidationWaiters[probeKey, default: []].append(( + id: waiterID, + continuation: continuation + )) + if Task.isCancelled { + cancelForkSupportValidationWaiter( + waiterID, + for: probeKey, + pendingRequestIDsToRemoveOnCancellation: pendingRequestIDsToRemoveOnCancellation + ) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelForkSupportValidationWaiter( + waiterID, + for: probeKey, + pendingRequestIDsToRemoveOnCancellation: pendingRequestIDsToRemoveOnCancellation + ) } - activeForkSupportValidationWaiters[probeKey, default: []].append(continuation) } } + private func cancelForkSupportValidationWaiter( + _ waiterID: UUID, + for probeKey: ForkProbeKey, + pendingRequestIDsToRemoveOnCancellation: [ForkProbeKey: Set] + ) { + guard var waiters = activeForkSupportValidationWaiters[probeKey], + let index = waiters.firstIndex(where: { $0.id == waiterID }) else { + return + } + let waiter = waiters.remove(at: index) + if waiters.isEmpty { + activeForkSupportValidationWaiters.removeValue(forKey: probeKey) + } else { + activeForkSupportValidationWaiters[probeKey] = waiters + } + removeOrMarkCancelledForkValidationRequests(pendingRequestIDsToRemoveOnCancellation) + waiter.continuation.resume() + } + private func waitForActiveForkSupportValidationIdentity(_ waitKey: ForkValidationWaitKey) async { let waiterID = UUID() await withTaskCancellationHandler { @@ -1371,7 +1424,7 @@ final class SharedLiveAgentIndex { return false } for waiter in waiters { - waiter.resume() + waiter.continuation.resume() } return !waiters.isEmpty } @@ -1613,7 +1666,10 @@ final class SharedLiveAgentIndex { ) requeuedPendingRequests = true deferredForkAvailabilityRefreshAfterActiveValidation = true - await waitForActiveForkSupportValidation(resolvedProbeKey) + await waitForActiveForkSupportValidation( + resolvedProbeKey, + pendingRequestIDsToRemoveOnCancellation: pendingRequestIDsToRemoveOnCancellation + ) guard !Task.isCancelled else { removeOrMarkCancelledForkValidationRequests(pendingRequestIDsToRemoveOnCancellation) return processedPanelIdsByWorkspaceId diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index d8a14a51d6a0..fc7d296a1311 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -292,18 +292,15 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS func dismantleContainerView(_ container: SidebarWorkspaceTableContainerView) { guard containerView === container else { return } let preserveNativeDragPresentation = hasActiveWorkspaceDragPresentation - // A writer can outlive this representable before AppKit calls - // `willBeginAt`. Keep only the immutable action snapshot and the source - // table/delegate path needed for that callback. The table is retained - // by the writer; the surrounding container and its hosted cells can be - // detached immediately, so repeated reconstruction cannot accumulate - // whole sidebar graphs. + // A writer can outlive this representable before AppKit calls `willBeginAt`. + // Keep only the writer-owned source table; detach rebuilt containers immediately. let preserveProvisionalWorkspaceDrag = !preserveNativeDragPresentation && (workspaceDragWriterOwnership.hasPendingTokens || pendingWorkspaceDragWriter != nil) + var provisionalWriters: [SidebarWorkspaceDragPasteboardWriter] = [] if preserveProvisionalWorkspaceDrag { pendingWorkspaceDragActions = actions ?? pendingWorkspaceDragActions - var provisionalWriters = ( + provisionalWriters = ( pendingWorkspaceDragWriters.objectEnumerator()?.allObjects ?? [] ).compactMap { $0 as? SidebarWorkspaceDragPasteboardWriter } if let pendingWorkspaceDragWriter, @@ -327,6 +324,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } } } + let provisionalWriterBelongsToContainer = preserveProvisionalWorkspaceDrag + && provisionalWriters.contains { $0.sourceViewForDrag === container.tableView } if preserveNativeDragPresentation, activeWorkspaceDragContainerView == nil { activeWorkspaceDragContainerView = container installDeferredDropLifecycle(on: container) @@ -382,7 +381,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS clearDropViewActions(in: container) } setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) - if !preserveNativeDragPresentation && !preserveProvisionalWorkspaceDrag { + if !preserveNativeDragPresentation, + !provisionalWriterBelongsToContainer { detachController(from: container.tableView) } container.clipView.workspaceController = nil diff --git a/Sources/Surfaces/CloudWorkspaceRenameService+Admission.swift b/Sources/Surfaces/CloudWorkspaceRenameService+Admission.swift index 11ce9d3ca592..ae72ec221c2e 100644 --- a/Sources/Surfaces/CloudWorkspaceRenameService+Admission.swift +++ b/Sources/Surfaces/CloudWorkspaceRenameService+Admission.swift @@ -20,9 +20,14 @@ extension CloudWorkspaceRenameService { if owner.panelCustomTitles[projection.panelID] != nil, (owner.panelCustomTitleSources[projection.panelID] ?? .user) == .user { return false } } - let accepted = catalog.pendingCloudRenameName(for: .tab(machine: resource.machine, id: tabID)) - ?? resource.remoteViews?.first(where: { $0.tabID == tabID })?.name ?? "" - return accepted.isEmpty || (workspace.panelCustomTitleSources[panelID] == .auto - && workspace.panelCustomTitles[panelID] == accepted) + let pending = catalog.pendingCloudRenameName(for: .tab(machine: resource.machine, id: tabID)) + let accepted = pending ?? resource.remoteViews?.first(where: { $0.tabID == tabID })?.name ?? "" + if accepted.isEmpty { return true } + // An accepted name reconciles locally as `.remote`, so the daemon's + // name authority says whether an agent owns it and may replace it. + if pending == nil, let tab = catalog.cloudStates[resource.machine]?.lookupIndex.tab(id: tabID), + tab.name == accepted, tab.nameAuthority?.source == .auto { return true } + return workspace.panelCustomTitleSources[panelID] == .auto + && workspace.panelCustomTitles[panelID] == accepted } } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 2d18cae448a5..17c89407417d 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -119,6 +119,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { portForwards: CloudHubPortForwarder? = nil, attachmentClock: any Clock = ContinuousClock(), portAccessStore: CloudPortAccessStore? = nil, + displayCoordinator: CloudDisplayCoordinator? = nil, browserPolicy: @escaping @MainActor () -> BrowserURLAllowlistPolicy = { BrowserURLAllowlistPolicy() } ) { machineID = summary.id @@ -128,7 +129,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { self.catalog = catalog self.portForwards = portForwards self.portAccessStore = portAccessStore ?? CloudPortAccessStore() - self.displayCoordinator = CloudDisplayCoordinator { command, timeout in + self.displayCoordinator = displayCoordinator ?? CloudDisplayCoordinator { command, timeout in guard let client = VMClient.shared else { throw ProviderError.notSignedIn } return try await client.exec(id: summary.id, command: command, timeoutMs: timeout) } @@ -253,12 +254,12 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let resources: [SurfaceResource] if let cloudState { let parsed = CmuxTuiSnapshotParser.mergingDisplays( - pool: hasDesktop ? [desktopDisplayResource()] : [], + pool: hasDesktop ? displayResources : [], parsed: CmuxTuiSnapshotParser.resources(from: cloudState) ) + Self.portResources(machine: machine, scannedPorts: scannedPorts, previousResources: previousResources, privateAddress: summary.preferredPrivateAddress, displayPortsOwned: hasDesktop) resources = resourcesWithPendingCreations(parsed, state: cloudState) } else { - var fallback = hasDesktop ? [desktopDisplayResource()] : [] + var fallback = hasDesktop ? displayResources : [] fallback.append(contentsOf: Self.portResources(machine: machine, scannedPorts: scannedPorts, previousResources: previousResources, privateAddress: summary.preferredPrivateAddress, displayPortsOwned: hasDesktop)) appendMissingResources(preservedNonPortResources, to: &fallback) resources = resourcesWithPendingCreations(fallback, state: nil) @@ -275,7 +276,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { // connect must not leave the desktop unopenable. Opening it forwards the // private noVNC port over the user-space hub (`materializeBrowserPane`). if hasDesktop, catalog.authoritativeSnapshot.resources(on: machine).isEmpty { - catalog.replaceResources([desktopDisplayResource()], on: machine, info: info, from: self) + catalog.replaceResources(displayResources, on: machine, info: info, from: self) } async let stats = try? client.stats(id: machineID) var linkState: SurfaceLinkState = .connected @@ -386,7 +387,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { syncNotifications(from: cloudState) } else { let resources = resourcesWithPendingCreations( - hasDesktop ? [desktopDisplayResource()] : [], + hasDesktop ? displayResources : [], state: nil ) var fallback = resources @@ -548,7 +549,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var pool: [SurfaceResource] = [] // The control plane's resolved kind is authoritative. Freestyle snapshot if summary.resolvedKind.hasDesktop { - pool.append(desktopDisplayResource()) + pool.append(contentsOf: displayResources) } var resources = CmuxTuiSnapshotParser.mergingDisplays( pool: pool, @@ -588,9 +589,9 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var resources = CmuxTuiSnapshotParser.resources(from: state, matching: affected) resources = resourcesWithPendingCreations(resources, state: state) if summary.resolvedKind.hasDesktop, - affected.contains(SurfaceResourceID(machine: machine, kind: .display, key: "display:1")) { + affected.contains(where: { $0.kind == .display }) { resources = CmuxTuiSnapshotParser.mergingDisplays( - pool: [desktopDisplayResource()], + pool: displayResources.filter { affected.contains($0.id) }, parsed: resources ) } @@ -1252,25 +1253,6 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { : "\(machineID):\(port)" } - /// The desktop row. Its URL is the machine's private noVNC address, so opening it - /// never needs a provider preview endpoint; nil until the machine has an address. - private func desktopDisplayResource() -> SurfaceResource { - CmuxTuiSnapshotParser.display( - machine: machine, - directURL: summary.preferredPrivateAddress.map { Self.privateDesktopURL(privateAddress: $0) } - ) - } - - /// The noVNC URL uses only the VM private address. The private network is - /// the access check, so no public preview token or endpoint is required. - nonisolated static func privateDesktopURL(privateAddress: String) -> String { - let base = CmuxInternalHostnames().directPortURL( - privateAddress: privateAddress, - port: CmuxTuiSnapshotParser.desktopPort - ) - return "\(base)/vnc.html?path=websockify&autoconnect=1&resize=remote&reconnect=1&reconnect_delay=2000" - } - /// Turn a VM-local browser URL into the same URL on the VM private address. /// Path, query, fragment, scheme, and port stay unchanged. nonisolated static func privateBrowserURL(_ raw: String, privateAddress: String) -> String? { @@ -1295,7 +1277,9 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var updated = resource switch resource.kind { case .display: - updated.url = privateDesktopURL(privateAddress: privateAddress) + // Each guest display keeps its own noVNC port; one without a + // discovered port has no target rather than aliasing display 1. + updated.url = resource.port.map { privateDesktopURL(privateAddress: privateAddress, port: $0) } case .browser: if resource.id.key.hasPrefix("port:"), let port = resource.port { updated.url = CmuxInternalHostnames().directPortURL( diff --git a/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift b/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift index 547b5759dff5..8660d2c61d95 100644 --- a/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift +++ b/Sources/Surfaces/SurfaceCatalog+NameAuthority.swift @@ -26,6 +26,13 @@ extension SurfaceCatalog { let key = CloudRenameCoordinator.Key.tab(machine: machine, id: tabID) let write: Task if source == .auto { + guard cloudWorkspaceRenameService.admitsTerminalRename( + workspace: workspace, + panelID: panelID, + resource: resource, + source: .auto, + catalog: self + ) else { return false } guard !name.isEmpty, let context, context == cloudAgentNameContext(workspaceID: workspace.id, panelID: panelID), cloudRenameCoordinator.pendingName(for: key) == nil, diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 7f3c9290e9f9..0fb7ae8799a4 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -1316,10 +1316,19 @@ final class SurfaceCatalog { /// becomes live as soon as the provider reports the resource again (a cloud terminal /// after the link reconnects); local resources are re-registered by the local provider /// with the same panel-derived key, so they resolve immediately. - func restore(_ records: [SurfaceProjectionRecord], workspaceID: UUID) { + /// + /// Session restore rebuilds a workspace before its `TabManager` publishes it, so no + /// app lookup can resolve the destination yet; that caller passes the workspace it is + /// restoring as `restoringWorkspace` and ownership is checked against it directly. + func restore(_ records: [SurfaceProjectionRecord], workspaceID: UUID, restoringWorkspace: Workspace? = nil) { + let destination = restoringWorkspace.flatMap { $0.id == workspaceID ? $0 : nil } for record in records where DockSplitStore.liveStore(containingPanel: record.panelID)?.scope != .global { - do { try validateOwnership(of: [record.resource], at: .workspace(id: workspaceID, placement: .tab)) } - catch { return } + if let destination { + if ownershipRejection(for: [record.resource], policy: destination.surfaceOwnershipPolicy) != nil { return } + } else { + do { try validateOwnership(of: [record.resource], at: .workspace(id: workspaceID, placement: .tab)) } + catch { return } + } } var wokenMachines = Set() for record in records { diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index 053d4231fadc..d7e1436faedb 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -147,6 +147,8 @@ extension Workspace { isLoading: Bool, index: Int? = nil ) throws -> UUID { + let previousPane = bonsplitController.focusedPaneId + let previousTab = previousPane.flatMap { bonsplitController.selectedTab(inPane: $0)?.id } panels[panel.id] = panel panelTitles[panel.id] = Self.cloudManualMirrorTabTitle guard let tab = bonsplitController.createTab( @@ -175,7 +177,12 @@ extension Workspace { panel.surface.flushPendingManualSizeReportIfAttached() if focus { focusPanel(panel.id) - } else { + } else if let previousPane { + // Creating a tab can select its target pane as a Bonsplit side + // effect. A non-focused projection must preserve the caller's + // active pane/tab so layout admission cannot steal keyboard focus. + bonsplitController.focusPane(previousPane) + if let previousTab { bonsplitController.selectTab(previousTab) } panel.unfocus() } return panel.id diff --git a/Sources/Surfaces/Workspace+SurfaceCatalog.swift b/Sources/Surfaces/Workspace+SurfaceCatalog.swift index b6b716a9d657..79a9a73930bb 100644 --- a/Sources/Surfaces/Workspace+SurfaceCatalog.swift +++ b/Sources/Surfaces/Workspace+SurfaceCatalog.swift @@ -55,6 +55,6 @@ extension Workspace { remoteTabID: record.remoteTabID ) } - SurfaceCatalog.shared.restore(remapped, workspaceID: id) + SurfaceCatalog.shared.restore(remapped, workspaceID: id, restoringWorkspace: self) } } diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index f91f40124fb1..7ae94f8cdc3b 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -57,6 +57,10 @@ extension TerminalController { "mobile.compatible_tags.set", "mobile.task.attachment.upload", "mobile.task.models.list", + // Socket-reachable panel artifact reads (worker lane); fetch stays + // behind the authenticated mobile execution context. + "mobile.panel.artifact.stat", + "mobile.panel.artifact.thumbnail", "mobile.workspace.list", "mobile.terminal.create", "mobile.terminal.input", diff --git a/Sources/TerminalController+WorkspaceCreate.swift b/Sources/TerminalController+WorkspaceCreate.swift index 721f601e7985..400fde596b33 100644 --- a/Sources/TerminalController+WorkspaceCreate.swift +++ b/Sources/TerminalController+WorkspaceCreate.swift @@ -297,6 +297,7 @@ extension TerminalController { generatedTitle: generatedTitle ) return .ok([ + "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "vm_id": vmID, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cd7101113308..f1a12d07c773 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2027,8 +2027,13 @@ extension Workspace { // that id (duplicate-workspace / restore-into-live can collide); // otherwise fall back to a fresh id and let the old->new remap // handle it, exactly as before. - let reusableSurfaceId: UUID? = - GhosttyApp.terminalSurfaceRegistry.surface(id: snapshot.id) == nil ? snapshot.id : nil + // A colliding restore must still present a restore identity: since + // #13098, `newTerminalSurfaceOutcome` treats a nil `restoredSurfaceId` + // as an interactive create and routes it to the selected pane's Cloud + // source, which replaces a legacy managed-Cloud SSH attach with a + // remote tab create. A fresh id is free by construction. + let reusableSurfaceId: UUID = + GhosttyApp.terminalSurfaceRegistry.surface(id: snapshot.id) == nil ? snapshot.id : UUID() guard let terminalPanel = newTerminalSurface( inPane: paneId, focus: false, diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 56f9a3ead9bf..4142503cf34c 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -184,7 +184,12 @@ struct WorkspaceContentView: View { var body: some View { #if DEBUG - let _ = { minimalModeInvalidationProbe.workspaceContentBody?() }() + let _ = { + if minimalModeInvalidationProbe.shouldTraceBodyChanges?() == true { + Self._printChanges() + } + minimalModeInvalidationProbe.workspaceContentBody?() + }() #endif let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 442d3e8094d7..dacbd6e4f302 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2189,6 +2189,7 @@ 110437021104370211043702 /* LiveAgentSessionOwnerObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110437011104370111043701 /* LiveAgentSessionOwnerObservation.swift */; }; 110439021104390211043902 /* LiveAgentSessionOwnerSessionKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110439011104390111043901 /* LiveAgentSessionOwnerSessionKey.swift */; }; D10799020000000000000001 /* LiveTabDragCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10799010000000000000001 /* LiveTabDragCapabilityResolver.swift */; }; + DB326399C791DD70E9D6997A /* LiveWorkspaceFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B507A66B0F521925687F2ED /* LiveWorkspaceFixture.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; 8F3CFE9C065FF1BD53D150CF /* LocalSurfaceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDD243C7C87A2AB78B12BAD /* LocalSurfaceProvider.swift */; }; 540A9F26DCDFD0C16B257D6F /* LocalSurfaceProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C68B378B6071B213B4AA57 /* LocalSurfaceProviderTests.swift */; }; @@ -6310,6 +6311,7 @@ 110437011104370111043701 /* LiveAgentSessionOwnerObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAgentSessionOwnerObservation.swift; sourceTree = ""; }; 110439011104390111043901 /* LiveAgentSessionOwnerSessionKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAgentSessionOwnerSessionKey.swift; sourceTree = ""; }; D10799010000000000000001 /* LiveTabDragCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTabDragCapabilityResolver.swift; sourceTree = ""; }; + 7B507A66B0F521925687F2ED /* LiveWorkspaceFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveWorkspaceFixture.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 1EDD243C7C87A2AB78B12BAD /* LocalSurfaceProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalSurfaceProvider.swift; sourceTree = ""; }; 64C68B378B6071B213B4AA57 /* LocalSurfaceProviderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalSurfaceProviderTests.swift; sourceTree = ""; }; @@ -12013,6 +12015,7 @@ F3E5958FB85E4B97957D4F73 /* CloudSidebarSurfaceRegressionTests.swift */, 9EFB28A7B5DF48ED8EF9CFB6 /* CloudDirectoryLifecycleTests.swift */, FFC7BA3C7C834D20B3173D36 /* CloudDirectoryTestFixture.swift */, + 7B507A66B0F521925687F2ED /* LiveWorkspaceFixture.swift */, 64C68B378B6071B213B4AA57 /* LocalSurfaceProviderTests.swift */, BC427A4C642EBC36E600FA7F /* CmuxTuiSurfaceProviderTests.swift */, F8FED5F6485C390988555CDA /* CloudFileDeliveryTests.swift */, @@ -16721,6 +16724,7 @@ 8582A1000000000000000002 /* KimiResumeReviewRegressionTests.swift in Sources */, 87C609D63F03597AC12C8B0A /* LastSurfaceClosePreferenceTests.swift in Sources */, 9492CAFE0000000000000002 /* LastTerminalChildExitRecoveryTests.swift in Sources */, + DB326399C791DD70E9D6997A /* LiveWorkspaceFixture.swift in Sources */, 540A9F26DCDFD0C16B257D6F /* LocalSurfaceProviderTests.swift in Sources */, C11218000000000000000010 /* LocalTmuxCommandBuilder.swift in Sources */, C11218000000000000000011 /* LocalTmuxProcessRunner.swift in Sources */, diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index e41f96856616..e8ac25f3d185 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -275,10 +275,16 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { input ) XCTAssertFalse(input.contains("/tmp/repo"), input) + // The remote cwd survives restore as the panel's trusted remote + // directory report, not as the host shell's spawn directory: the + // restored resume input owns the `cd`, and a remote-host path is + // never enterable locally (OneShotTerminalLauncherStore filters it), + // so seeding it as the local spawn cwd would break the owning shell. XCTAssertEqual( - restoredPanel.requestedWorkingDirectory, + restored.panelDirectories[restoredPanelId], remoteWorkingDirectory ) + XCTAssertNil(restoredPanel.requestedWorkingDirectory) XCTAssertEqual( restored.restoredAgentResumeStatesByPanelId[restoredPanelId], .awaitingAutoResumeCommand diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index 7410b1ba69c1..26243792cabd 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -758,7 +758,15 @@ struct AgentSessionAutoResumeSwiftTests { orientation: .horizontal, focus: false )) - #expect(split.requestedWorkingDirectory == projectDir) + // The split runs the workspace's SSH startup command, so since + // #12054 its cwd travels as the remote initial cwd instead of a + // local PTY cwd. The rescued session directory (not the clobbered + // tracked home) must still be the one handed over. + #expect(split.requestedWorkingDirectory == nil) + #expect( + split.surface.startupEnvironmentValue(Workspace.remoteInitialWorkingDirectoryEnvironmentKey) + == projectDir + ) } } diff --git a/cmuxTests/AppDelegateMainWindowTestingSupport.swift b/cmuxTests/AppDelegateMainWindowTestingSupport.swift index 655143c4650d..758938ce2f1c 100644 --- a/cmuxTests/AppDelegateMainWindowTestingSupport.swift +++ b/cmuxTests/AppDelegateMainWindowTestingSupport.swift @@ -58,10 +58,22 @@ actor AppContextSerialGate { /// route after they finish exercising its recovery behavior. extension AppDelegate { /// Establishes the real window/controller/terminal focus relationship before input probes. + /// + /// `makeKeyAndOrderFront` only makes a programmatic window key while the + /// test host is the active app. The app-host process starts inactive under + /// `xcodebuild test`, so callers that use a real `createMainWindow()` + /// window (which cannot be swapped for `KeyStatusTestWindow`) became key + /// only when an earlier test in the shard happened to activate the app. + /// Activate explicitly so the terminal focus paths that gate on + /// `isKeyWindow` are exercised by behavior rather than by test order. func focusTerminalForTesting(_ panel: TerminalPanel, workspace: Workspace, in window: NSWindow) async -> Bool { + NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() - guard await AppKitTestEventPump().waitUntil({ + // Activation and the resulting key-window transition land on later main + // run-loop turns, and a contended CI runner can take several of them; + // the pump's one-second default expires before the window goes key. + guard await AppKitTestEventPump().waitUntil(timeout: .seconds(10), { panel.hostedView.uiWindow === window && panel.hostedView.surfaceView.window === window && panel.hostedView.bounds.width > 1 @@ -139,3 +151,15 @@ extension AppDelegate { } } } + +/// A window that reports key status the way the focused main window does in +/// the running app. The app-host test process runs headless under +/// `xcodebuild test` and is usually not the active app, so +/// `makeKeyAndOrderFront` never makes a programmatic window key; whether it +/// does then depends on whether an earlier test happened to activate the app. +/// Terminal focus paths gate on `isKeyWindow` (automatic first-responder +/// apply, focus redraws, deferred focus reapply), so focus tests that do not +/// pin key status pass or fail by test order instead of by behavior. +final class KeyStatusTestWindow: NSWindow { + override var isKeyWindow: Bool { true } +} diff --git a/cmuxTests/AppDelegateOptionDigitShortcutRoutingTests.swift b/cmuxTests/AppDelegateOptionDigitShortcutRoutingTests.swift index 0ea12d5c5074..a4225adf9046 100644 --- a/cmuxTests/AppDelegateOptionDigitShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateOptionDigitShortcutRoutingTests.swift @@ -486,8 +486,11 @@ struct AppDelegateOptionDigitShortcutRoutingTests { defer { closeWindow(withId: windowId) } let testWindow = try #require(self.window(withId: windowId)) - // Cmd+Shift+Y is now a Cloud default; this test isolates modifier - // matching for focus history rather than shortcut conflict priority. + // Cmd+Shift+Y (New Cloud Workspace) and Cmd+Y (New Cloud Machine) are + // Cloud defaults that swapped strokes in 9c2ba78be4; this test isolates + // modifier matching for focus history rather than shortcut conflict + // priority, so clear both regardless of which one owns the shifted key. + KeyboardShortcutSettings.clearShortcut(for: .newCloudWorkspace) KeyboardShortcutSettings.clearShortcut(for: .newCloudMachine) let candidates: [(KeyboardShortcutSettings.Action, StoredShortcut, NSEvent.ModifierFlags, String, String, UInt16)] = [ ( diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 0d4355d0ecc4..fcfb3c5f7375 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -4363,6 +4363,20 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + // Ghostty's imported goto_split:next binding (⌘] in its macOS defaults) is + // a compatibility fallback that matches the physical ANSI `]` key (keyCode + // 30) once Focus Forward / Browser Forward are unbound. It is unrelated to + // digit coercion, so take it out of the picture the way the Cmd+Shift+] + // sibling does for `.nextSurface`. + let originalGhosttyPrevious = appDelegate.ghosttyGotoSplitPreviousShortcut + let originalGhosttyNext = appDelegate.ghosttyGotoSplitNextShortcut + appDelegate.ghosttyGotoSplitPreviousShortcut = nil + appDelegate.ghosttyGotoSplitNextShortcut = nil + defer { + appDelegate.ghosttyGotoSplitPreviousShortcut = originalGhosttyPrevious + appDelegate.ghosttyGotoSplitNextShortcut = originalGhosttyNext + } + withTemporaryShortcut( action: .showNotifications, shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false) @@ -4388,6 +4402,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } #if DEBUG + XCTAssertFalse( + appDelegate.debugMatchesConfiguredShortcut(event: event, action: .showNotifications), + "Cmd+* on the ANSI ] key must not match the Cmd+8 digit shortcut" + ) XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") @@ -7016,6 +7034,18 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #if DEBUG XCTAssertEqual(repairProbe.repairCount(), 1, "window.sendEvent should run the focused terminal repair path") XCTAssertTrue(repairProbe.repairResponder() === strayView, "Repair should evaluate the simulated wrong same-window responder") + // A cold surface queues the repaired keyDown and requests an input-demand + // runtime start, so the forward into libghostty lands only after that + // asynchronous start completes. Give it a chance before judging, and skip + // loudly (as the stranded-responder sibling does) when this host never + // spins a runtime surface up, so the oracle cannot silently vanish. + waitUntil(timeout: 5.0) { + repairProbe.forwardedKeyDownCount() > 0 + } + try XCTSkipUnless( + terminalPanel.surface.hasLiveSurface, + "No live libghostty surface on this host, so keyDown forwarding cannot be observed" + ) XCTAssertGreaterThan( repairProbe.forwardedKeyDownCount(), 0, @@ -7216,7 +7246,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let manager = appDelegate.tabManagerFor(windowId: windowId), let workspace = manager.selectedWorkspace, let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { + let terminalPanel = workspace.terminalPanel(for: panelId), + let terminalView = surfaceView(in: terminalPanel.hostedView) else { XCTFail("Expected focused terminal panel") return } @@ -7231,7 +7262,20 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { window.displayIfNeeded() terminalPanel.hostedView.setVisibleInUI(true) terminalPanel.hostedView.setActive(true) + // The portal mounts the terminal surface asynchronously after the window + // is created; Escape can only hand focus back to a mounted surface, and + // `focusTerminalSurface` bails out without scheduling a retry otherwise. + waitFor(timeout: 1.0, until: { terminalView.window === window }) + XCTAssertTrue(terminalView.window === window, "Expected terminal surface to mount in the test window") terminalPanel.hostedView.moveFocus() + waitFor( + timeout: 1.0, + until: { terminalPanel.hostedView.isSurfaceViewFirstResponder() } + ) + XCTAssertTrue( + terminalPanel.hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to own first responder before TextBox focus" + ) terminalPanel.registerTextBoxInputView(textBoxView) XCTAssertTrue(terminalPanel.toggleTextBoxInput()) waitFor( @@ -8439,6 +8483,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { func testTextBoxSubmitSerializesPasteboardRunsAcrossSurfaces() throws { #if DEBUG try withPreservedGeneralPasteboard { + // A previous app-host batch may have been interrupted while a + // pasteboard lane was awaiting its fake read. Start this isolated + // cross-surface ordering check from an empty debug runner. + TextBoxSubmit.debugResetForTesting() let firstSurface = FakeTextBoxSubmitSurface() let secondSurface = FakeTextBoxSubmitSurface() let pasteboard = NSPasteboard.general @@ -8507,10 +8555,28 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { func testTextBoxSubmitKeepsQueuedRunForStillActiveSurfaceWhenAnotherSurfaceFinishes() throws { #if DEBUG try withPreservedGeneralPasteboard { + // A previous app-host batch may have been interrupted while a + // pasteboard lane was awaiting its fake read, which leaves the + // process-wide pasteboard run reserved and silently queues this + // run instead of starting it. Start from an empty debug runner. + TextBoxSubmit.debugResetForTesting() let activeSurface = FakeTextBoxSubmitSurface() let finishingSurface = FakeTextBoxSubmitSurface() TextBoxSubmit.debugWaitTimeoutSecondsOverride = 10 defer { TextBoxSubmit.debugWaitTimeoutSecondsOverride = nil } + // The file paste publishes through the managed pasteboard lane, + // whose previous-contents capture runs in a re-exec'd helper + // process. Wait on the binding callback itself instead of a + // wall-clock deadline sized for a warm spawn: on a loaded CI host + // that cold helper launch alone has been observed taking longer + // than the old 5s poll, which turned a correct run into a failure. + let bindingPerformed = expectation( + description: "file paste performs the clipboard paste binding" + ) + activeSurface.performExplicitInputBindingActionHandler = { + bindingPerformed.fulfill() + return true + } let imageURL = try makeTemporaryPNGFile(named: "moon.png") var completions: [String] = [] @@ -8538,10 +8604,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { completions.append("finishing") } - waitFor(timeout: 5.0, until: { - completions == ["finishing"] && - activeSurface.sentKeys == ["paste_from_clipboard"] - }) + // The binding callback is the real signal that the lane published + // the temporary clipboard; the timeout only bounds the failure path. + wait(for: [bindingPerformed], timeout: 60.0) + XCTAssertEqual(completions, ["finishing"]) XCTAssertEqual(finishingSurface.sentText, ["finishing"]) XCTAssertEqual(activeSurface.sentText, []) XCTAssertEqual(activeSurface.sentKeys, ["paste_from_clipboard"]) diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 45b4e13eaa20..99c499b623f8 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1956,11 +1956,16 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { ) throws -> NSColor { let terminal = try XCTUnwrap(terminalColor.usingColorSpace(.sRGB)) // Match `GhosttyBackgroundTheme.resolvedColor`, which resolves semantic - // window colors against Ghostty's terminal appearance. Reading the - // ambient AppKit color would make this assertion host-appearance dependent. + // window colors against Ghostty's effective terminal color scheme rather + // than the ambient AppKit appearance. That scheme is itself host-dependent + // (light on a fresh CI host), so read the same preference the product + // consults instead of assuming dark. let base = try XCTUnwrap( WindowAppearanceSnapshot - .resolvedColor(.windowBackgroundColor, for: .dark) + .resolvedColor( + .windowBackgroundColor, + for: GhosttyApp.shared.effectiveTerminalColorSchemePreference == .dark ? .dark : .light + ) .usingColorSpace(.sRGB) ) return NSColor( diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index e3758f4f73ee..5032c4412a03 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -2,6 +2,7 @@ import AppKit import Darwin import Foundation import Testing +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -49,6 +50,77 @@ private final class BrowserDiscardRestoreRefusedEndpoint { deinit { Darwin.close(descriptor) } } +/// WebKit builds a `WKNavigation`'s embedded C++ `API::Navigation` itself, so an +/// instance made with a bare `WKNavigation()` carries unconstructed storage. +/// Allocating one is harmless; releasing it is not — `-[WKNavigation dealloc]` +/// traps and takes the whole xctest host down. The discard-restore bookkeeping +/// only ever compares these by identity, so minting them here and holding them +/// for the run is behaviour-preserving. Same rationale, and same shape, as +/// `BrowserDiscardRestoreHealPredicateTests`. +@MainActor +private enum BrowserDiscardRestoreNavigationStub { + private static var retained: [WKNavigation] = [] + + static func make() -> WKNavigation { + let navigation = WKNavigation() + retained.append(navigation) + return navigation + } +} + +/// Records WebKit's navigation commands without starting a network load, and +/// reports a navigation object for a main-frame load so the panel's +/// discard-restore bookkeeping tracks the attempt exactly as it does for a real +/// one. Sibling of `BrowserReloadRecordingWebView`, which returns nil instead — +/// that would make the panel treat every restore as "navigation_not_started" +/// and would not exercise the refused-connection path this case is about. +@MainActor +private final class BrowserDiscardRestoreFakeWebView: WKWebView { + private(set) var requests: [URLRequest] = [] + private(set) var errorPageLoadCount = 0 + + override func load(_ request: URLRequest) -> WKNavigation? { + requests.append(request) + return BrowserDiscardRestoreNavigationStub.make() + } + + override func loadHTMLString(_: String, baseURL _: URL?) -> WKNavigation? { + errorPageLoadCount += 1 + return nil + } +} + +/// Swaps the panel's live web view for a fake navigation source. `webViewInstanceID` +/// is deliberately left alone, so the navigation-delegate callbacks the panel +/// installed stay bound to whatever `panel.webView` is now. +@MainActor +@discardableResult +private func installFakeNavigationSource(in panel: BrowserPanel) -> BrowserDiscardRestoreFakeWebView { + panel.detachWebViewObservers() + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = panel.websiteDataStore + let webView = BrowserDiscardRestoreFakeWebView(frame: .zero, configuration: configuration) + panel.webView = webView + return webView +} + +/// Reports a refused connection for `url` through the panel's real navigation +/// delegate, the way `BrowserFailedNavigationReloadTests` does. Every state +/// transition it drives — failure bookkeeping, error page, retry policy — is +/// synchronous, so no caller has to wait for anything. +@MainActor +private func refuseConnection(to url: URL, in panel: BrowserPanel) { + panel.navigationDelegate?.webView( + panel.webView, + didFailProvisionalNavigation: nil, + withError: NSError( + domain: NSURLErrorDomain, + code: NSURLErrorCannotConnectToHost, + userInfo: [NSURLErrorFailingURLStringErrorKey: url.absoluteString] + ) + ) +} + @MainActor private func withBrowserDiscardRestoreRetryPolicyEnabled(_ body: (UserDefaults) -> Void) { let suiteName = "com.cmux.BrowserDiscardedWebViewRestoreRetryTests.\(UUID().uuidString)" @@ -113,7 +185,7 @@ struct BrowserDiscardedWebViewRestoreRetryTests { } } - @Test func browserPanelRetriesDiscardedRestoreAfterConnectionRefused() async throws { + @Test func browserPanelRetriesDiscardedRestoreAfterConnectionRefused() throws { // RED(#7504): connection-refused restore must leave the pane retryable on the next restore touch. let endpoint = try BrowserDiscardRestoreRefusedEndpoint(path: "cmux-issue-7504") defer { withExtendedLifetime(endpoint) {} } @@ -127,41 +199,41 @@ struct BrowserDiscardedWebViewRestoreRetryTests { ) defer { panel.close() } - // This case exercises real WebKit failure callbacks. Give both the - // original and replacement views a sized native host before loading. - let window = NSWindow( - contentRect: NSRect(x: 20, y: 20, width: 640, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - window.contentView = panel.webView - window.orderBack(nil) - defer { - window.contentView = nil - window.close() - } + // `WKWebView.isLoading` is not a completion signal: on the app-host + // runners the provisional load to the reserved loopback port neither + // fails nor commits, and `isLoading` stays true even after + // `stopLoading()`. The retry bookkeeping under test needs no real load + // at all, so drive the panel from a fake navigation source and report + // the refusal through the navigation delegate, the terminal callback + // that actually owns this transition. The restore bookkeeping, the error + // page, and the retry policy all run unchanged, and every transition is + // synchronous — nothing here waits on WebKit. + let originalSource = installFakeNavigationSource(in: panel) try #require(panel.navigate(to: url) != nil) + #expect(originalSource.requests.last?.url == url) - try #require(await AppKitTestEventPump().waitUntil(timeout: .seconds(30)) { - panel.navigationDelegate?.activeErrorPageDisplayURL == url - && !panel.webView.isLoading && !panel.isLoading - }) + refuseConnection(to: url, in: panel) + #expect(panel.navigationDelegate?.activeErrorPageDisplayURL == url) + #expect(originalSource.errorPageLoadCount == 1) + #expect(!panel.webView.isLoading) + #expect(!panel.isLoading) panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) let originalWebView = panel.webView #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) #expect(panel.webView !== originalWebView) - window.contentView = panel.webView + let restoreSource = installFakeNavigationSource(in: panel) #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore1")) - try #require(await AppKitTestEventPump().waitUntil(timeout: .seconds(20)) { - let restorePending = panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool ?? false - return panel.navigationDelegate?.activeErrorPageDisplayURL == url - && !restorePending && !panel.webView.isLoading && !panel.isLoading - }) + #expect(restoreSource.requests.last?.url == url) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == true) + + refuseConnection(to: url, in: panel) + #expect(panel.navigationDelegate?.activeErrorPageDisplayURL == url) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(!panel.webView.isLoading) + #expect(!panel.isLoading) #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) } diff --git a/cmuxTests/BrowserFailedNavigationReloadTests.swift b/cmuxTests/BrowserFailedNavigationReloadTests.swift index d01c49ab4080..fe680fc0e6e7 100644 --- a/cmuxTests/BrowserFailedNavigationReloadTests.swift +++ b/cmuxTests/BrowserFailedNavigationReloadTests.swift @@ -138,7 +138,10 @@ struct BrowserFailedNavigationReloadTests { BrowserWindowPortalRegistry.refresh(webView: webView, reason: "test.paneReplacement") #expect(webView.superview === slot) - #expect(webView.isDescendant(of: try #require(window.contentView))) + // The portal host is a window-level sibling of `contentView` unless the + // window installs a content-hosted browser root (#12929); the rebind + // contract is that the web view stays in the same window. + #expect(webView.window === window) #expect(BrowserWindowPortalRegistry.isPresented(webView)) #expect(webView.requests.isEmpty) #expect(webView.reloadCount == 0) diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index aff5d0680076..ec8952e13a2a 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -30,9 +30,13 @@ struct BrowserWebViewUserAgentRegressionTests { "Mozilla/5.0 Chrome/125.0.0.0 Safari/537.36" panel.navigate(to: URL(string: "about:blank")!) + // WebKit reports the native identity as nil or "" depending on the load + // phase (see `applyBrowserUserAgentPolicy`); either means the Chrome + // override was dropped before the non-web navigation started. + let userAgent = panel.webView.customUserAgent ?? "" #expect( - panel.webView.customUserAgent == nil, - "Embedded WKWebView must keep its native identity so canvas apps do not select Safari-only rendering paths" + userAgent.isEmpty, + "Embedded WKWebView must keep its native identity so canvas apps do not select Safari-only rendering paths (was \(userAgent))" ) } } @@ -1323,27 +1327,6 @@ final class WindowBrowserHostViewTests: XCTestCase { } } - /// Models an AppKit hosting wrapper that claims its entire bounds instead - /// of forwarding `hitTest` to a nested SwiftUI overlay. - private final class NonPropagatingHostingView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - /// Models a hosting wrapper whose native hit-test result is an unrelated - /// content leaf, even though a divider tracker is a sibling in the same - /// hosted hierarchy. This is the shape produced by some AppKit/SwiftUI - /// wrappers while their content subtree is being reconciled. - private final class LeafClaimingHostingView: NSView { - weak var claimedLeaf: NSView? - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - return claimedLeaf ?? self - } - } - private final class FakeTabBarBackgroundNSView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil @@ -1635,181 +1618,6 @@ final class WindowBrowserHostViewTests: XCTestCase { ) } - func testHostViewForwardsDockDividerMouseDownToLiveSidebarTracker() throws { - // The portal host is a window-level sibling above the SwiftUI tree. A - // browser pane immediately left of the Dock must hand ownership to the - // host and let it forward the synchronous divider drag to the native - // tracker below, even when a hosting wrapper claims the underlying hit. - // The second drag below also models the short SwiftUI reparent gap that - // can occur between AppKit's hit-test and mouseDown callbacks. - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected window content container") - return - } - - let wrapper = NonPropagatingHostingView(frame: contentView.bounds) - contentView.addSubview(wrapper) - - var eventNames: [String] = [] - var changedTranslation: Double? - let liveDivider = SidebarDividerTrackingView( - frame: NSRect(x: 206, y: 0, width: 10, height: contentView.bounds.height) - ) - liveDivider.onBegan = { eventNames.append("began") } - liveDivider.onChanged = { translation in - eventNames.append("changed") - changedTranslation = Double(translation) - // Post the terminating event only after the tracker has received - // the drag, so its synchronous loop exercises the real callback - // path without relying on a timer or a test sleep. - let up = self.makeMouseEvent( - type: .leftMouseUp, - location: NSPoint(x: 238, y: 130), - window: window - ) - window.postEvent(up, atStart: true) - } - liveDivider.onEnded = { eventNames.append("ended") } - wrapper.addSubview(liveDivider) - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let paneWidth: CGFloat = 210 - let browserSlot = WindowBrowserSlotView( - frame: NSRect(x: 0, y: 0, width: paneWidth, height: host.bounds.height) - ) - let dockSlot = WindowBrowserSlotView( - frame: NSRect(x: paneWidth, y: 0, width: paneWidth, height: host.bounds.height) - ) - browserSlot.addSubview(CapturingView(frame: browserSlot.bounds)) - dockSlot.addSubview(CapturingView(frame: dockSlot.bounds)) - host.addSubview(browserSlot) - host.addSubview(dockSlot) - - let dock = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) - let dockPane = try XCTUnwrap(dock.bonsplitController.allPaneIds.first) - dockSlot.setPaneDropContext(BrowserPaneDropContext( - workspaceId: dock.workspaceId, - panelId: UUID(), - paneId: dockPane, - isDockHosted: true - )) - defer { dock.retire() } - - window.makeKeyAndOrderFront(nil) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: paneWidth, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - let down = self.makeMouseEvent( - type: .leftMouseDown, - location: dividerPointInWindow, - window: window - ) - let hit = container.hitTest(container.convert(dividerPointInWindow, from: nil)) - XCTAssertTrue( - hit === host, - "The portal must own the shared divider before forwarding it to the live sidebar tracker. actual=\(String(describing: hit))" - ) - guard hit === host else { return } - - let drag = self.makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 32, y: dividerPointInWindow.y), - window: window - ) - window.postEvent(drag, atStart: true) - host.mouseDown(with: down) - - XCTAssertEqual( - eventNames, - ["began", "changed", "ended"], - "A shared browser/Dock divider hit must follow the native sidebar drag lifecycle" - ) - guard let forwardedTranslation = changedTranslation else { - XCTFail("The forwarded drag must report a native sidebar translation") - return - } - XCTAssertEqual( - Double(forwardedTranslation), - 32.0, - accuracy: 0.5, - "The forwarded drag must reach the native sidebar tracker" - ) - - // AppKit may ask for another hit-test while SwiftUI is moving the - // divider tracker between hosting wrappers. Preserve the original - // handoff even if the concrete tracker is detached for that turn. - eventNames.removeAll() - changedTranslation = nil - let reparentDown = self.makeMouseEvent( - type: .leftMouseDown, - location: dividerPointInWindow, - window: window - ) - let pasteboard = NSPasteboard( - name: NSPasteboard.Name("cmux.test.issue-10892.reparent.\(UUID().uuidString)") - ) - pasteboard.clearContents() - XCTAssertTrue( - host.performHitTest( - at: dividerPointInHost, - currentEvent: reparentDown, - dragPasteboard: pasteboard - ) === host, - "The portal must record the Dock divider handoff before a tracker reparent" - ) - - liveDivider.removeFromSuperview() - XCTAssertNil( - host.performHitTest( - at: dividerPointInHost, - currentEvent: reparentDown, - dragPasteboard: pasteboard - ), - "A transiently detached tracker should still leave the browser/Dock divider pass-through path" - ) - XCTAssertTrue( - host.acceptsFirstMouse(for: reparentDown), - "The portal must not consume the retained Dock handoff while AppKit checks first-mouse activation" - ) - - let reparentDrag = self.makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 28, y: dividerPointInWindow.y), - window: window - ) - window.postEvent(reparentDrag, atStart: true) - host.mouseDown(with: reparentDown) - - XCTAssertEqual( - eventNames, - ["began", "changed", "ended"], - "A Dock divider handoff must survive a transient tracker reparent" - ) - guard let reparentedTranslation = changedTranslation else { - XCTFail("A reparented Dock divider must report a native drag translation") - return - } - XCTAssertEqual( - Double(reparentedTranslation), - 28.0, - accuracy: 0.5, - "A reparented Dock divider must continue receiving native drag translation" - ) - } - func testHostViewKeepsDockDividerPassThroughDuringTransientPortalContextClear() throws { // Portal reparenting can briefly clear a visible slot's drop context while // preserving its existing frame. The Dock divider must remain owned by the @@ -1979,102 +1787,17 @@ final class WindowBrowserHostViewTests: XCTestCase { ) pasteboard.clearContents() - XCTAssertTrue( + XCTAssertNil( host.performHitTest( at: dividerPointInHost, currentEvent: event, dragPasteboard: pasteboard - ) === host, - "The browser portal must own the stale-frame Dock divider before forwarding it to the live tracker" - ) - - let mainContentPoint = NSPoint(x: 100, y: host.bounds.midY) - XCTAssertTrue( - host.performHitTest( - at: mainContentPoint, - currentEvent: event, - dragPasteboard: pasteboard - ) === mainContent, - "Following the live divider must not make ordinary browser content pass through" - ) - } - - func testHostViewReturnsLiveSidebarDividerThroughHostingWrapper() throws { - // SwiftUI can place the native divider tracker below an AppKit hosting - // wrapper whose hitTest returns the wrapper itself. The browser portal - // must own the event itself and forward it to the live tracker, rather - // than returning a sibling that AppKit may dispatch inconsistently. - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected window content container") - return - } - - let wrapper = NonPropagatingHostingView(frame: contentView.bounds) - contentView.addSubview(wrapper) - let liveDivider = SidebarDividerTrackingView( - frame: NSRect(x: 206, y: 0, width: 10, height: contentView.bounds.height) - ) - liveDivider.onBegan = {} - liveDivider.onChanged = { _ in } - liveDivider.onEnded = {} - wrapper.addSubview(liveDivider) - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let mainSlot = WindowBrowserSlotView( - frame: NSRect(x: 0, y: 0, width: 210, height: host.bounds.height) - ) - let staleDockSlot = WindowBrowserSlotView( - frame: NSRect(x: 300, y: 0, width: 120, height: host.bounds.height) - ) - let mainContent = CapturingView(frame: mainSlot.bounds) - let staleDockContent = CapturingView(frame: staleDockSlot.bounds) - mainSlot.addSubview(mainContent) - staleDockSlot.addSubview(staleDockContent) - host.addSubview(mainSlot) - host.addSubview(staleDockSlot) - - let dock = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) - let dockPane = try XCTUnwrap(dock.bonsplitController.allPaneIds.first) - staleDockSlot.setPaneDropContext(BrowserPaneDropContext( - workspaceId: dock.workspaceId, - panelId: UUID(), - paneId: dockPane, - isDockHosted: true - )) - defer { dock.retire() } - - contentView.layoutSubtreeIfNeeded() - let dividerPointInHost = NSPoint(x: 210, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - let event = makeMouseEvent( - type: .leftMouseDown, - location: dividerPointInWindow, - window: window - ) - let pasteboard = NSPasteboard( - name: NSPasteboard.Name("cmux.test.issue-10892.hosting-wrapper.\(UUID().uuidString)") + ), + "The browser portal must yield the stale-frame Dock divider to the live tracker underneath" ) - pasteboard.clearContents() - XCTAssertTrue( - host.performHitTest( - at: dividerPointInHost, - currentEvent: event, - dragPasteboard: pasteboard - ) === host, - "The portal must own a Dock divider hit even when its hosting wrapper claims hit testing" + container.hitTest(container.convert(dividerPointInWindow, from: nil)) === liveDivider, + "AppKit's normal hit-test path must reach the live Dock divider once the portal yields" ) let mainContentPoint = NSPoint(x: 100, y: host.bounds.midY) @@ -2084,219 +1807,7 @@ final class WindowBrowserHostViewTests: XCTestCase { currentEvent: event, dragPasteboard: pasteboard ) === mainContent, - "A hosting-wrapper fallback must not make ordinary browser content pass through" - ) - } - - func testHostViewFindsLiveSidebarDividerWhenHostingWrapperClaimsContentLeaf() throws { - // A SwiftUI/AppKit wrapper can report a content leaf for the pointer - // even when the native divider tracker is its sibling. The portal must - // inspect the visible hosted hierarchy, rather than treating that leaf - // as proof that no sidebar divider is present. - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected window content container") - return - } - - let wrapper = LeafClaimingHostingView(frame: contentView.bounds) - let liveDivider = SidebarDividerTrackingView( - frame: NSRect(x: 206, y: 0, width: 10, height: contentView.bounds.height) - ) - liveDivider.onBegan = {} - liveDivider.onChanged = { _ in } - liveDivider.onEnded = {} - let claimedContentLeaf = CapturingView(frame: wrapper.bounds) - wrapper.claimedLeaf = claimedContentLeaf - wrapper.addSubview(liveDivider) - wrapper.addSubview(claimedContentLeaf) - contentView.addSubview(wrapper) - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - // The browser pane and Dock pane share an edge, matching the reported - // `new-pane --type browser --direction right` topology. - let paneWidth: CGFloat = 210 - let browserSlot = WindowBrowserSlotView( - frame: NSRect(x: 0, y: 0, width: paneWidth, height: host.bounds.height) - ) - let dockSlot = WindowBrowserSlotView( - frame: NSRect(x: paneWidth, y: 0, width: paneWidth, height: host.bounds.height) - ) - let browserContent = CapturingView(frame: browserSlot.bounds) - let dockContent = CapturingView(frame: dockSlot.bounds) - browserSlot.addSubview(browserContent) - dockSlot.addSubview(dockContent) - host.addSubview(browserSlot) - host.addSubview(dockSlot) - - let dock = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) - let dockPane = try XCTUnwrap(dock.bonsplitController.allPaneIds.first) - dockSlot.setPaneDropContext(BrowserPaneDropContext( - workspaceId: dock.workspaceId, - panelId: UUID(), - paneId: dockPane, - isDockHosted: true - )) - defer { dock.retire() } - - contentView.layoutSubtreeIfNeeded() - let dividerPointInHost = NSPoint(x: paneWidth, y: host.bounds.midY) - let dividerPointInContent = contentView.convert( - host.convert(dividerPointInHost, to: nil), - from: nil - ) - XCTAssertTrue( - wrapper.hitTest(wrapper.convert(dividerPointInContent, from: contentView)) === claimedContentLeaf, - "The fixture must model a hosting wrapper that claims an unrelated content leaf" - ) - - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - let event = makeMouseEvent( - type: .leftMouseDown, - location: dividerPointInWindow, - window: window - ) - let pasteboard = NSPasteboard( - name: NSPasteboard.Name("cmux.test.issue-10892.wrapper-leaf.\(UUID().uuidString)") - ) - pasteboard.clearContents() - - XCTAssertTrue( - host.performHitTest( - at: dividerPointInHost, - currentEvent: event, - dragPasteboard: pasteboard - ) === host, - "The browser portal must own the shared browser/Dock divider when a wrapper claims a content leaf" - ) - - let rootPointInContainer = container.convert(dividerPointInWindow, from: nil) - XCTAssertTrue( - container.hitTest(rootPointInContainer) === host, - "The window's normal AppKit hit-test path must preserve portal ownership for the live Dock divider" - ) - - let browserPoint = NSPoint(x: paneWidth - 32, y: host.bounds.midY) - XCTAssertTrue( - host.performHitTest( - at: browserPoint, - currentEvent: event, - dragPasteboard: pasteboard - ) === browserContent, - "Finding a live tracker must not make ordinary browser content pass through" - ) - } - - func testHostViewPrefersLiveSidebarDividerOverlappingHostedSplit() throws { - // A stale WebKit/NSSplitView divider can overlap the real Dock divider - // for one layout turn. The live sidebar tracker is the concrete owner - // at that point; a hosted-content region must not preempt it. - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected window content container") - return - } - - let liveDivider = SidebarDividerTrackingView( - frame: NSRect(x: 206, y: 0, width: 10, height: contentView.bounds.height) - ) - liveDivider.onBegan = {} - liveDivider.onChanged = { _ in } - liveDivider.onEnded = {} - contentView.addSubview(liveDivider) - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let mainSlot = WindowBrowserSlotView( - frame: NSRect(x: 0, y: 0, width: 210, height: host.bounds.height) - ) - let staleDockSlot = WindowBrowserSlotView( - frame: NSRect(x: 300, y: 0, width: 120, height: host.bounds.height) - ) - let mainContent = CapturingView(frame: mainSlot.bounds) - let staleDockContent = CapturingView(frame: staleDockSlot.bounds) - mainSlot.addSubview(mainContent) - staleDockSlot.addSubview(staleDockContent) - host.addSubview(mainSlot) - host.addSubview(staleDockSlot) - - let hostedSplit = NSSplitView( - frame: NSRect(x: 200, y: 0, width: 20, height: host.bounds.height) - ) - hostedSplit.isVertical = true - hostedSplit.dividerStyle = .thin - hostedSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 10, height: hostedSplit.bounds.height))) - hostedSplit.addSubview(NSView(frame: NSRect(x: 11, y: 0, width: 9, height: hostedSplit.bounds.height))) - host.addSubview(hostedSplit) - - let dock = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) - let dockPane = try XCTUnwrap(dock.bonsplitController.allPaneIds.first) - staleDockSlot.setPaneDropContext(BrowserPaneDropContext( - workspaceId: dock.workspaceId, - panelId: UUID(), - paneId: dockPane, - isDockHosted: true - )) - defer { dock.retire() } - - contentView.layoutSubtreeIfNeeded() - hostedSplit.layoutSubtreeIfNeeded() - let hostedDividerX = hostedSplit.convert( - NSPoint(x: hostedSplit.arrangedSubviews[0].frame.maxX, y: hostedSplit.bounds.midY), - to: host - ).x - let liveDividerOrigin = contentView.convert( - NSPoint(x: hostedDividerX - 5, y: 0), - from: host - ) - liveDivider.frame = NSRect( - x: liveDividerOrigin.x, - y: liveDividerOrigin.y, - width: 10, - height: contentView.bounds.height - ) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: hostedDividerX, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - let event = makeMouseEvent( - type: .leftMouseDown, - location: dividerPointInWindow, - window: window - ) - let pasteboard = NSPasteboard( - name: NSPasteboard.Name("cmux.test.issue-10892.hosted-overlap.\(UUID().uuidString)") - ) - pasteboard.clearContents() - - XCTAssertTrue( - host.performHitTest( - at: dividerPointInHost, - currentEvent: event, - dragPasteboard: pasteboard - ) === host, - "A live Dock tracker must make the portal host win over a stale hosted vertical divider at the same point" + "Following the live divider must not make ordinary browser content pass through" ) } diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index bb07cbdc077f..e455fdbb68ee 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -1248,7 +1248,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { context.state.commands.contains { $0.hasPrefix("set_agent_pid claude_code ") }, "A fork SessionStart without an authoritative surface must not register its PID on a borrowed fallback pane, saw \(context.state.commands)" ) - XCTAssertThrowsError(try readClaudeHookSession(childSessionId, context: context)) + let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") + let store = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try XCTUnwrap(store["sessions"] as? [String: Any]) + XCTAssertNil(sessions[childSessionId], "An unattributed fork must not acquire a fallback pane's session identity") } func testClaudeForkSessionStartUsesPayloadIdentityWithEqualsFlagForm() throws { @@ -1974,7 +1977,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + XCTAssertEqual(result.stdout, "{}\n") let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) XCTAssertNil( @@ -2103,13 +2106,16 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { defer { context.cleanup() } let sessionId = "same-process-session" + let transcriptURL = context.root.appendingPathComponent("codex-nested-resume.jsonl") + try #"{"type":"session_meta","payload":{"id":"\#(sessionId)","source":"cli","originator":"codex-tui"}}"# + .write(to: transcriptURL, atomically: true, encoding: .utf8) let launchEnvironment = codexLaunchEnvironment(context: context, sessionId: sessionId) startAgentHookMockServerAccepting(context: context) let parentPrompt = runCodexHook( context: context, subcommand: "prompt-submit", - standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"spawn subagent"}"#, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"spawn subagent"}"#, extraEnvironment: launchEnvironment ) XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) @@ -2123,7 +2129,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let childPrompt = runCodexHook( context: context, subcommand: "prompt-submit", - standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"return 1+1"}"#, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"return 1+1"}"#, extraEnvironment: launchEnvironment ) XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) @@ -2142,7 +2148,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let childStop = runCodexHook( context: context, subcommand: "stop", - standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"2"}"#, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"2"}"#, extraEnvironment: launchEnvironment ) XCTAssertFalse(childStop.timedOut, childStop.stderr) @@ -2165,7 +2171,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let parentStop = runCodexHook( context: context, subcommand: "stop", - standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) XCTAssertFalse(parentStop.timedOut, parentStop.stderr) @@ -2899,6 +2905,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let sessionId = "terminal-stack-reset-session" let transcriptURL = context.root.appendingPathComponent("codex-terminal-stack-reset.jsonl") try [ + #"{"type":"session_meta","payload":{"id":"\#(sessionId)","source":"cli","originator":"codex-tui"}}"#, #"{"type":"turn_context","payload":{"turn_id":"parent-turn"}}"#, #"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"parent-turn"}}"#, #"{"type":"turn_context","payload":{"turn_id":"child-turn"}}"#, @@ -3553,6 +3560,16 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) + let terminalObservations = currentStopCommands.compactMap { command -> [String: Any]? in + let prefix = "agent_journal_append " + guard command.hasPrefix(prefix), + let event = self.jsonObject(String(command.dropFirst(prefix.count))), + event["kind"] as? String == "agent.idle.observed" else { return nil } + return event + } + XCTAssertEqual(terminalObservations.compactMap { ($0["attention"] as? [String: Any])?["turnIdentity"] as? String }, ["old-turn"]) + XCTAssertTrue(terminalObservations.allSatisfy { ($0["attention"] as? [String: Any])?["notification"] == nil }) + XCTAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A Stop after a missed prompt-submit must clear terminal stale turns and notify, saw \(currentStopCommands)" @@ -3996,7 +4013,8 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertNil(configureParams["ssh_options"]) + let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) + XCTAssertFalse(sshOptions.contains { $0.lowercased().hasPrefix("forwardagent") }) XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } @@ -4331,7 +4349,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stdout.isEmpty, result.stdout) XCTAssertTrue(result.stderr.contains("ssh-pty-attach: remote PTY start failed"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) + // Once `pty_bridge` has established the endpoint, only remote reconciliation + // may retire the lifecycle or release the surface (#12726); a fatal bridge + // status before READY therefore leaves the local surface for Reconnect. + XCTAssertEqual(methods, ["workspace.remote.pty_bridge"]) } func testSSHPTYAttachExhaustedZeroOutputBridgeEOFReleasesSurface() throws { @@ -4463,12 +4484,14 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } XCTAssertEqual( methods.filter { $0 != "workspace.remote.terminal_session_connected" && $0 != "workspace.remote.pty_resize" }, + // Reconciliation answered that the session is still running, so the + // established lifecycle is retained for Reconnect (#12726): the + // attachment is detached, but neither `acknowledge_lifecycle` nor + // `pty_attach_end` is sent. [ "workspace.remote.pty_bridge", "workspace.remote.pty_sessions", "workspace.remote.pty_detach", - "workspace.remote.pty_sessions", - "workspace.remote.pty_attach_end", ] ) // Immediate EOF may cancel the independent readiness and resize tasks. @@ -5459,8 +5482,17 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.create") let params = payload["params"] as? [String: Any] ?? [:] + if method == "surface.ssh_session_attach.resolve" { + XCTAssertEqual(params["workspace_id"] as? String, workspaceId) + XCTAssertEqual(params["session_id"] as? String, sessionId) + return self.v2Response( + id: id, + ok: true, + result: ["workspace_id": workspaceId, "workspace_ref": "workspace:1"] + ) + } + XCTAssertEqual(method, "surface.create") XCTAssertEqual(params["workspace_id"] as? String, workspaceId) XCTAssertEqual(params["remote_pty_session_id"] as? String, sessionId) XCTAssertEqual(params["focus"] as? Bool, true) @@ -5479,7 +5511,24 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { && initialCommand.contains("CMUX_SSH_RECONNECT_LIMIT"), initialCommand ) - XCTAssertEqual(initialCommand.components(separatedBy: "/usr/bin/uuidgen").count - 1, 2, initialCommand) + // Exactly two identity UUIDs: the per-attempt id and the once-per-attach + // lifecycle id. The auth event token helper (#11497) mints its own + // optional token via `/usr/bin/uuidgen 2>/dev/null`, asserted separately. + XCTAssertEqual( + initialCommand.components(separatedBy: "$(/usr/bin/uuidgen | /usr/bin/tr").count - 1, + 2, + initialCommand + ) + XCTAssertEqual( + initialCommand.components(separatedBy: "cmux_ssh_attach_lifecycle_id=$(/usr/bin/uuidgen").count - 1, + 1, + initialCommand + ) + XCTAssertEqual( + initialCommand.components(separatedBy: "cmux_ssh_attach_auth_event_token=$(/usr/bin/uuidgen 2>/dev/null").count - 1, + 1, + initialCommand + ) XCTAssertTrue(initialCommand.contains("ssh-session-end --lifecycle-only"), initialCommand) return self.v2Response( id: id, @@ -5515,7 +5564,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 0, result.stderr) XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual(state.snapshot().count, 1) + XCTAssertEqual(state.snapshot().count, 2) } func testSSHPTYAttachRequireExistingPassesBridgeFlag() throws { @@ -5606,7 +5655,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(result.status, 1, result.stderr) XCTAssertTrue(result.stderr.contains("ssh-pty-attach: missing session"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) + // Once `pty_bridge` has established the endpoint, only remote reconciliation + // may retire the lifecycle or release the surface (#12726); a fatal bridge + // status before READY therefore leaves the local surface for Reconnect. + XCTAssertEqual(methods, ["workspace.remote.pty_bridge"]) } func testSSHPTYAttachSessionNotFoundFailsAfterOneRespawnAttempt() throws { @@ -5749,9 +5801,25 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stderr) XCTAssertEqual(result.status, 1, result.stderr) XCTAssertFalse(result.stdout.contains("No persisted SSH PTY sessions"), result.stdout) - XCTAssertTrue(result.stderr.contains("ssh-session-list failed for 1 remote workspace"), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace:4"), result.stderr) - XCTAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) + XCTAssertTrue(result.stderr.contains("Remote PTY session state is unavailable"), result.stderr) + + // Human output uses the safe summary; structured output retains the + // exact workspace and query failure for diagnostics. + let structured = runProcess( + executablePath: cliPath, + arguments: ["--json", "ssh-session-list", "--all-workspaces"], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(structured.timedOut, structured.stderr) + XCTAssertEqual(structured.status, 1, structured.stderr) + let payload = try XCTUnwrap(jsonObject(structured.stdout)) + XCTAssertEqual((payload["sessions"] as? [[String: Any]])?.count, 0) + let errors = try XCTUnwrap(payload["errors"] as? [[String: Any]]) + XCTAssertEqual(errors.count, 1) + let error = try XCTUnwrap(errors.first) + XCTAssertEqual(error["workspace_ref"] as? String, "workspace:4") + XCTAssertEqual(error["error"] as? String, "remote connection is not active") } func testSSHSessionCleanupAllReportsPartialFailures() throws { @@ -7484,10 +7552,13 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ], ] ) - case "surface.send_text": + case "surface.respawn": XCTAssertEqual(params["window_id"] as? String, windowId) XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["text"] as? String, "echo fresh\n") + XCTAssertEqual(params["tmux_start_command"] as? String, "echo fresh") + let command = params["command"] as? String ?? "" + XCTAssertTrue(command.contains("echo fresh"), command) + XCTAssertFalse(command.contains("--window"), command) return self.v2Response(id: id, ok: true, result: ["surface_id": surfaceId]) default: return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) @@ -7511,7 +7582,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(result.status, 0, result.stderr) XCTAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, - ["window.list", "system.identify", "surface.send_text"] + ["window.list", "system.identify", "surface.respawn"] ) } @@ -8712,6 +8783,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { try? FileManager.default.removeItem(at: root) } + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try #"{"type":"session_meta","payload":{"id":"\#(sessionId)","source":"cli","originator":"codex-tui"}}"# + .write(to: transcriptURL, atomically: true, encoding: .utf8) + let storeURL = root.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) let now = Date().timeIntervalSince1970 let store: [String: Any] = [ @@ -8808,7 +8883,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { executablePath: cliPath, arguments: ["hooks", "codex", "prompt-submit"], environment: environment, - standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, timeout: 5 ) @@ -10295,7 +10370,17 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { return try XCTUnwrap(decodedReusableStartupScript(from: try XCTUnwrap(createParams["initial_command"] as? String))) } private func decodedReusableStartupScript(from command: String) -> String? { - SSHStartupCommandTestSupport.decodedScript(in: command) + // A fresh terminal receives a script path; restored terminals + // carry the same generated script inline as base64. + if command.range(of: "^/[A-Za-z0-9_@%+=:,./-]+$", options: .regularExpression) != nil { + return try? String(contentsOfFile: command, encoding: .utf8) + } + if command.hasPrefix("'/"), command.hasSuffix("'") { + let path = String(command.dropFirst().dropLast()) + .replacingOccurrences(of: "'\"'\"'", with: "'") + return try? String(contentsOfFile: path, encoding: .utf8) + } + return SSHStartupCommandTestSupport.decodedScript(in: command) } private func params(for method: String, in requests: [[String: Any]]) -> [String: Any]? { requests diff --git a/cmuxTests/CLIVMDevTests.swift b/cmuxTests/CLIVMDevTests.swift index ab6ebe7a3958..47e3353045c3 100644 --- a/cmuxTests/CLIVMDevTests.swift +++ b/cmuxTests/CLIVMDevTests.swift @@ -152,6 +152,7 @@ extension CLINotifyProcessIntegrationRegressionTests { _ name: String, arguments: [String], home: URL, + expectsSocketConnection: Bool = true, respond: @escaping @Sendable (_ method: String, _ params: [String: Any]) -> [String: Any]? ) throws -> (result: ProcessRunResult, log: VMDevRequestLog) { let cliPath = try bundledCLIPath() @@ -163,14 +164,37 @@ extension CLINotifyProcessIntegrationRegressionTests { Darwin.close(listenerFD) unlink(socketPath) } - let serverHandled = startVMDevMock(listenerFD: listenerFD, state: state, log: log, respond: respond) + let serverHandled: XCTestExpectation? + if expectsSocketConnection { + serverHandled = startVMDevMock(listenerFD: listenerFD, state: state, log: log, respond: respond) + } else { + // Invalid input is rejected before connecting, so the mock's expectation + // would only be fulfilled by the listener closing, which happens after the + // wait. Keep the socket listening instead and check its backlog once the + // process has exited: an accidental connection is still observable. + let flags = fcntl(listenerFD, F_GETFL) + XCTAssertGreaterThanOrEqual(flags, 0) + XCTAssertEqual(fcntl(listenerFD, F_SETFL, flags | O_NONBLOCK), 0) + serverHandled = nil + } let result = runProcess( executablePath: cliPath, arguments: arguments, environment: vmDevEnvironment(socketPath: socketPath, home: home), timeout: 60 ) - wait(for: [serverHandled], timeout: 60) + if let serverHandled { + wait(for: [serverHandled], timeout: 60) + } else { + let unexpectedClient = Darwin.accept(listenerFD, nil, nil) + let acceptError = errno + if unexpectedClient >= 0 { + Darwin.close(unexpectedClient) + XCTFail("Invalid vm dev input must not open a socket connection") + } else { + XCTAssertTrue(acceptError == EAGAIN || acceptError == EWOULDBLOCK) + } + } XCTAssertFalse(result.timedOut, "\(arguments) timed out: \(result.stderr)") return (result, log) } @@ -643,7 +667,8 @@ extension CLINotifyProcessIntegrationRegressionTests { let (invalidLayout, invalidLog) = try runVMDev( "vm-dev-bad-layout", arguments: ["vm", "dev", "brave-otter", fixture.project.path, "--layout", badLayout], - home: fixture.home + home: fixture.home, + expectsSocketConnection: false ) { _, _ in nil } XCTAssertEqual(invalidLayout.status, 2, "stdout=\(invalidLayout.stdout) stderr=\(invalidLayout.stderr)") XCTAssertTrue(invalidLayout.stderr.contains("invalid layout document"), invalidLayout.stderr) @@ -653,7 +678,8 @@ extension CLINotifyProcessIntegrationRegressionTests { let (missingFolder, missingLog) = try runVMDev( "vm-dev-missing-folder", arguments: ["vm", "dev", "brave-otter", fixture.project.appendingPathComponent("nope").path], - home: fixture.home + home: fixture.home, + expectsSocketConnection: false ) { _, _ in nil } XCTAssertNotEqual(missingFolder.status, 0) XCTAssertTrue(missingFolder.stderr.contains("no such folder"), missingFolder.stderr) @@ -662,7 +688,8 @@ extension CLINotifyProcessIntegrationRegressionTests { let (badPort, badPortLog) = try runVMDev( "vm-dev-bad-port", arguments: ["vm", "dev", "brave-otter", fixture.project.path, "--port", "http"], - home: fixture.home + home: fixture.home, + expectsSocketConnection: false ) { _, _ in nil } XCTAssertNotEqual(badPort.status, 0) XCTAssertTrue(badPort.stderr.contains("--port must be a port number"), badPort.stderr) @@ -671,7 +698,8 @@ extension CLINotifyProcessIntegrationRegressionTests { let (noMachine, noMachineLog) = try runVMDev( "vm-dev-no-machine", arguments: ["vm", "dev"], - home: fixture.home + home: fixture.home, + expectsSocketConnection: false ) { _, _ in nil } XCTAssertNotEqual(noMachine.status, 0) XCTAssertTrue(noMachine.stderr.contains("Usage: cmux vm dev "), noMachine.stderr) diff --git a/cmuxTests/CloudClosedPanelRestoreTests.swift b/cmuxTests/CloudClosedPanelRestoreTests.swift index 19c388a59df7..2ce702dc9d45 100644 --- a/cmuxTests/CloudClosedPanelRestoreTests.swift +++ b/cmuxTests/CloudClosedPanelRestoreTests.swift @@ -14,7 +14,7 @@ import Testing struct CloudClosedPanelRestoreTests { @Test("Foreign and unowned displays cannot restore into a Cloud workspace", arguments: ["a", "b"]) func rejectsDisplayRestoreBeforeLayoutMutation(owner: String) throws { - try withManager { manager in + try withManager(registeredWithApp: true) { manager in let workspace = manager.addWorkspace(initialSurface: .browser, autoWelcomeIfNeeded: false) workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: owner, isBase: false) let panelID = try #require(workspace.focusedPanelId) @@ -48,7 +48,7 @@ struct CloudClosedPanelRestoreTests { @Test("Restoring a Cloud terminal reserves a manual mirror instead of a local shell") func cloudTerminalRestoreDoesNotBrieflyBecomeLocal() throws { - try withManager { manager in + try withManager(registeredWithApp: true) { manager in let workspace = manager.addWorkspace(initialSurface: .terminal, autoWelcomeIfNeeded: false) let oldID = try #require(workspace.focusedPanelId) let resource = SurfaceResourceID(machine: .cloud(UUID().uuidString), kind: .terminal, key: "terminal-restore") @@ -65,7 +65,7 @@ struct CloudClosedPanelRestoreTests { @Test("Closing a deferred Cloud browser keeps the last pane empty until reopen") func deferredBrowserReopensWithoutTerminal() throws { - try withManager { manager in + try withManager(registeredWithApp: true) { manager in let workspace = manager.addWorkspace(initialSurface: .browser, autoWelcomeIfNeeded: false) let browserID = try #require(workspace.focusedPanelId) let resource = SurfaceResourceID(machine: .cloud(UUID().uuidString), kind: .browser, key: "browser-1") @@ -217,7 +217,7 @@ struct CloudClosedPanelRestoreTests { } } - private func withManager(_ body: (TabManager) throws -> Void) throws { + private func withManager(registeredWithApp: Bool = false, _ body: (TabManager) throws -> Void) throws { let suite = "CloudClosedPanelRestoreTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.set(false, forKey: "closeWorkspaceOnLastSurfaceShortcut") @@ -236,6 +236,8 @@ struct CloudClosedPanelRestoreTests { ClosedItemHistoryStore.shared.removeAll() defaults.removePersistentDomain(forName: suite) } - try body(manager) + guard registeredWithApp else { return try body(manager) } + // `SurfaceCatalog.shared` restores projections only into a workspace the app resolves. + try LiveWorkspaceFixture.withAppRegistration(of: manager) { try body(manager) } } } diff --git a/cmuxTests/CloudDesktopAccessTests.swift b/cmuxTests/CloudDesktopAccessTests.swift index 56ae32d4400a..e0b8d28940d5 100644 --- a/cmuxTests/CloudDesktopAccessTests.swift +++ b/cmuxTests/CloudDesktopAccessTests.swift @@ -214,8 +214,10 @@ struct CloudDesktopAccessTests { @Test("Every Cloud website retains its requested URL through bootstrap commits", arguments: ["http://10.0.0.7:6901/vnc.html", "http://10.0.0.7:3000/", "https://10.0.0.7:8443/app"]) func desktopRetainsPendingServiceIdentity(rawURL: String) async throws { + let live = LiveWorkspaceFixture() + defer { live.tearDown() } let store = CloudPortAccessStore() - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let readiness = CloudLinkFirstValue() let remote = try #require(URL(string: rawURL)) let target = CloudPortForwardTarget(host: "10.0.0.7", port: remote.port!) @@ -230,7 +232,7 @@ struct CloudDesktopAccessTests { ) } let provider = provider(store: store, catalog: catalog) - let browser = BrowserPanel(workspaceId: UUID(), websiteDataStore: .nonPersistent()) + let browser = BrowserPanel(workspaceId: live.id(), websiteDataStore: .nonPersistent()) defer { browser.close(); readiness.resolve(nil) } // A delayed bootstrap commit from the original WebView must not replace // the Cloud identity while its authenticated replacement is being prepared. @@ -383,10 +385,12 @@ struct CloudDesktopAccessTests { }, stopForward: { stops += 1 }, route: .loopback) } model.acceptTunnelState(state) - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let provider = provider(store: store, catalog: catalog) - let first = BrowserPanel(workspaceId: UUID(), websiteDataStore: .nonPersistent()) - let second = BrowserPanel(workspaceId: UUID(), websiteDataStore: .nonPersistent()) + let first = BrowserPanel(workspaceId: live.id(), websiteDataStore: .nonPersistent()) + let second = BrowserPanel(workspaceId: live.id(), websiteDataStore: .nonPersistent()) defer { first.close(); second.close() } let remote = try #require(URL(string: CmuxTuiSurfaceProvider.privateDesktopURL(privateAddress: target.host))) @@ -409,11 +413,14 @@ struct CloudDesktopAccessTests { @Test("A private-origin deny rule cannot be bypassed by the loopback rewrite") func deniedPrivateOriginCreatesNoForward() { + // A live destination, so the URL policy (not ownership) is what refuses the page. + let live = LiveWorkspaceFixture() + defer { live.tearDown() } let store = CloudPortAccessStore() - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let policy = BrowserURLAllowlistPolicy(managedPatterns: ["allowed.example"], allowsLocalhost: true) let provider = provider(store: store, catalog: catalog, policy: policy) - let browser = BrowserPanel(workspaceId: UUID(), websiteDataStore: .nonPersistent()) + let browser = BrowserPanel(workspaceId: live.id(), websiteDataStore: .nonPersistent()) defer { browser.close() } provider.configureBrowser(browser, url: URL(string: "http://10.0.0.7:6901/vnc.html")!) #expect(policy.allowsTrustedInternalURL(URL(string: "http://127.0.0.1:46901")!)) diff --git a/cmuxTests/CloudDisplayCatalogTests.swift b/cmuxTests/CloudDisplayCatalogTests.swift index fa298a8e8968..fd24e04a561c 100644 --- a/cmuxTests/CloudDisplayCatalogTests.swift +++ b/cmuxTests/CloudDisplayCatalogTests.swift @@ -24,7 +24,7 @@ struct CloudDisplayCatalogTests { func creationRetryKeepsRequestIdentity() async throws { var creates: [String] = [] let service = CloudDisplayCoordinator { command, _ in - if command.contains(" list") { return .init(exitCode: 0, stdout: initial, stderr: "") } + if !Self.isCreate(command) { return .init(exitCode: 0, stdout: initial, stderr: "") } creates.append(command) if creates.count == 1 { throw URLError(.networkConnectionLost) } return .init(exitCode: 0, stdout: created, stderr: "") @@ -43,14 +43,14 @@ struct CloudDisplayCatalogTests { let started = CloudLinkFirstValue() let response = CloudLinkFirstValue() let service = CloudDisplayCoordinator { command, _ in - if command.contains(" list") { return .init(exitCode: 0, stdout: initial, stderr: "") } + if !Self.isCreate(command) { return .init(exitCode: 0, stdout: initial, stderr: "") } started.resolve(true) _ = await response.result return .init(exitCode: 0, stdout: created, stderr: "") } await service.refresh() let operation = Task { try await service.create() } - _ = await started.result + try #require(await Self.guestExecStarted(started, before: operation)) service.stop() response.resolve(true) do { _ = try await operation.value; Issue.record("Retired creation succeeded") } catch {} @@ -97,23 +97,21 @@ struct CloudDisplayCatalogTests { } @Test("Cancelling display creation cancels the guest exec") - func cancelledCreationCancelsGuestExec() async { + func cancelledCreationCancelsGuestExec() async throws { let started = CloudLinkFirstValue() let cancelled = CloudLinkFirstValue() + // Never resolved: the exec stays in flight until its task is cancelled. + let reply = CloudLinkFirstValue() let service = CloudDisplayCoordinator { command, _ in - if command.contains(" list") { return .init(exitCode: 0, stdout: initial, stderr: "") } + if !Self.isCreate(command) { return .init(exitCode: 0, stdout: initial, stderr: "") } started.resolve(true) - do { - try await Task.sleep(for: .seconds(60)) - } catch { - cancelled.resolve(true) - throw error - } - return .init(exitCode: 0, stdout: created, stderr: "") + _ = await reply.result + cancelled.resolve(Task.isCancelled) + throw CancellationError() } await service.refresh() let operation = Task { try await service.create() } - _ = await started.result + try #require(await Self.guestExecStarted(started, before: operation)) operation.cancel() #expect(await cancelled.result == true) do { _ = try await operation.value } catch {} @@ -194,6 +192,91 @@ struct CloudDisplayCatalogTests { #expect(second.remoteViews == pointer.remoteViews) } + @Test("Guest-created displays survive the next daemon publish and display delta") + func createdDisplaysSurviveDaemonPublish() async throws { + let machine = SurfaceMachineID.cloud("display-publish") + let coordinator = CloudDisplayCoordinator { command, _ in + .init(exitCode: 0, stdout: Self.isCreate(command) ? created : initial, stderr: "") + } + var summary = VMSummary(id: "display-publish", provider: "freestyle", status: "running", image: "cmux-devbox", createdAt: 0, base: nil) + summary.kind = .desktop + summary.addressIPv4 = "10.0.0.7" + let catalog = SurfaceCatalog() + let provider = CmuxTuiSurfaceProvider( + summary: summary, + links: CloudMachineLinkManager(clientURL: nil, hostThemeColors: { nil }), + catalog: catalog, + displayCoordinator: coordinator + ) + catalog.register(provider) + defer { catalog.unregister(machine: machine) } + await provider.refreshDisplays() + let second = try await provider.createDisplay() + #expect(second.id == SurfaceResourceID(machine: machine, kind: .display, key: "display:2")) + + func displayPorts() -> [String: Int?] { + Dictionary(uniqueKeysWithValues: catalog.snapshot.resources(on: machine) + .filter { $0.kind == .display } + .map { ($0.id.key, $0.port) }) + } + #expect(displayPorts() == ["display:1": 6901, "display:2": 6902]) + + // A daemon graph carries no display tabs; the guest catalog still owns both screens. + let state = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: [ + "cursor": ["generation": "daemon", "revision": "1"], + "workspaces": [["id": "ws_main", "name": "main", "focused": true]], + "screens": [["id": "screen", "workspace_id": "ws_main"]], + "panes": [["id": "pane", "screen_id": "screen"]], + "tabs": [["id": "tab_0", "pane_id": "pane", "content_kind": "terminal", "content_id": "term_0", "focused": true]], + "terminals": [["id": "term_0", "title": "bash", "lifecycle": "running"]], + "browsers": [], "agents": [] + ], machine: machine)) + #expect(provider.installSnapshotIfNewer(state)) + provider.publish(state, ports: []) + #expect(displayPorts() == ["display:1": 6901, "display:2": 6902], "a full publish keeps every guest display") + + provider.publishDelta( + state, + impact: CloudVMStateDeltaImpact(resourceIDs: [second.id], requiresFullResourceRebuild: false), + ports: [], + reconcileTitles: false + ) + #expect(displayPorts() == ["display:1": 6901, "display:2": 6902], "a delta touching display 2 keeps its target") + #expect(catalog.resources[second.id]?.url?.contains(":6902/") == true) + } + + /// Waits until the fake guest exec starts creating, or answers false when + /// `operation` finishes first. `create()` can fail before it reaches the + /// exec (a fake that no longer recognizes the create command, for example), + /// and awaiting `started` alone then parks the test until the suite time + /// limit, which relaunches the whole app host. + private static func guestExecStarted( + _ started: CloudLinkFirstValue, + before operation: Task + ) async -> Bool { + Task { + _ = await operation.result + started.resolve(false) + } + return await started.result == true + } + + /// Every guest command first runs `list` as a readiness probe (#13196, + /// 178d35e5da), so only the final action line tells a creation apart. + private static func isCreate(_ command: String) -> Bool { + command.contains("\"$path\" create --request-id ") + } + + @Test("Every guest command probes readiness; only creation carries a request ID") + func guestCommandShape() { + let request = UUID() + let create = CloudGuestDisplayScript.command(action: "create", requestID: request) + let list = CloudGuestDisplayScript.command(action: "list") + #expect(create.contains("\"$path\" list > /dev/null 2>&1 || exit 1")) + #expect(create.contains("\"$path\" create --request-id \(request.uuidString.lowercased())")) + #expect(Self.isCreate(create) && !Self.isCreate(list)) + } + private func decode(_ raw: String) throws -> CloudGuestDisplaySnapshot { try CloudGuestDisplaySnapshot(data: Data(raw.utf8)) } diff --git a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift index 12efba36d2a3..af277f3679c0 100644 --- a/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift +++ b/cmuxTests/CloudMachineWorkspaceAdoptionTests.swift @@ -78,8 +78,13 @@ struct CloudMachineWorkspaceAdoptionTests { let pane = try #require(pending.bonsplitController.allPaneIds.first) let command = try #require(pending.newTerminalSurface(inPane: pane, focus: false, initialCommand: "echo first-command", autoRefreshMetadata: false)) - NewMachineSheetPresenter.closeReservedWorkspace(pending.id) + pending.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "newer-machine", isBase: false) + NewMachineSheetPresenter.closeReservedWorkspace(pending.id, machineID: "cancelled-machine") #expect(app.manager.tabs.contains { $0.id == pending.id }) + #expect(pending.panels.count == 2 && pending.panels[command.id] === command) + #expect(pending.cloudVMBinding?.vmID == "newer-machine") + pending.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "cancelled-machine", isBase: false) + NewMachineSheetPresenter.closeReservedWorkspace(pending.id, machineID: "cancelled-machine") #expect(pending.panels.count == 1 && pending.panels[command.id] === command) #expect(pending.cloudVMBinding == nil) app.appDelegate.closeWorkspaces(forManagedCloudVMID: "cancelled-machine") @@ -291,7 +296,7 @@ struct CloudMachineWorkspaceAdoptionTests { provider.beforeMaterialization = { entered.resolve(true); _ = await release.result } let attachment = Task { try await open(pending, provider: provider, catalog: catalog) } _ = await entered.result - NewMachineSheetPresenter.closeReservedWorkspace(pending.id) + NewMachineSheetPresenter.closeReservedWorkspace(pending.id, machineID: provider.machine.rawValue) #expect(pending.cloudVMBinding == nil) // A later explicit open has a different admission claim and must not // join the cancelled adoption or be discarded with its late result. diff --git a/cmuxTests/CloudMachineWorkspaceResolutionTests.swift b/cmuxTests/CloudMachineWorkspaceResolutionTests.swift index 11b8ccce47af..ab95b4aae70a 100644 --- a/cmuxTests/CloudMachineWorkspaceResolutionTests.swift +++ b/cmuxTests/CloudMachineWorkspaceResolutionTests.swift @@ -22,8 +22,8 @@ struct CloudMachineWorkspaceResolutionTests { let resolver = VMRemoteWorkspaceResolver() #expect(resolver.resolveVMMachineTerminal(machine: "machine", catalog: catalog) == .resolved(workspaceID: "ws-later", terminalID: "term-later", tabID: "tab-later")) - #expect(resolver.resolveVMRemoteTerminalPlacement("first", machine: "machine", workspaceID: "ws-first", in: catalog) + #expect(resolver.resolveVMRemoteTerminalPlacement("term-first", machine: "machine", workspaceID: "ws-first", in: catalog) == .resolved(terminalID: "term-first", tabID: "tab-first")) - #expect(resolver.resolveVMRemoteTerminalPlacement("first", machine: "machine", workspaceID: "deleted", in: catalog) == .notFound) + #expect(resolver.resolveVMRemoteTerminalPlacement("term-first", machine: "machine", workspaceID: "deleted", in: catalog) == .notFound) } } diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 798967a2a986..9b444c48bbba 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -10,6 +10,12 @@ import Testing @Suite("Cloud manual mirror presentation") struct CloudManualMirrorPresentationTests { + /// Every AppKit-backed view under `root`, in depth-first order. + @MainActor + private static func descendants(of root: NSView) -> [NSView] { + root.subviews.flatMap { [$0] + descendants(of: $0) } + } + @Test("Reconnect card and controls stay inside a narrow Cloud split") @MainActor func reconnectCardFitsAfterPaneResize() throws { @@ -23,30 +29,63 @@ struct CloudManualMirrorPresentationTests { let content = try #require(window.contentView) let overlay = CloudTerminalReconnectOverlayView(frame: content.bounds) content.addSubview(overlay) - overlay.apply(.init( - title: "Cloud terminal could not start", - detail: "Check that the machine is connected, then retry this terminal.", - showsProgress: false, showsReconnectButton: true - )) - let card = try #require(overlay.subviews.first as? NSVisualEffectView) - let stack = try #require(card.subviews.compactMap { $0 as? NSStackView }.first) - let controls = stack.arrangedSubviews.compactMap { $0 as? NSControl } - + card.subviews.compactMap { $0 as? NSButton } + // #12609 replaced the AppKit reconnect card with a SwiftUI one, so the + // card is a hosting view and its title, detail and Retry control are + // drawn by SwiftUI rather than exposed as NSControls. Locate the card by + // the identifier the overlay sets on it, never by its AppKit class. + let card = try #require( + Self.descendants(of: overlay).first { + $0.accessibilityIdentifier() == CloudTerminalReconnectOverlayView.cardAccessibilityIdentifier + } + ) - for width: CGFloat in [720, 190, 320, 190] { - overlay.frame.size.width = width - content.layoutSubtreeIfNeeded() - #expect(card.frame.minX >= 0) - #expect(card.frame.maxX <= width) - for control in controls where !control.isHidden { - let rect = control.convert(control.bounds, to: overlay) - #expect(rect.minX >= 0 && rect.maxX <= width) + for showsProgress in [false, true] { + overlay.apply(.init( + title: "Cloud terminal could not start", + detail: "Check that the machine is connected, then retry this terminal.", + showsProgress: showsProgress, showsReconnectButton: true + )) + var cardWidthsByPaneWidth: [CGFloat: CGFloat] = [:] + for width: CGFloat in [720, 190, 320, 190] { + overlay.frame.size.width = width + overlay.needsLayout = true + content.layoutSubtreeIfNeeded() + cardWidthsByPaneWidth[width] = card.frame.width + #expect(card.frame.minX >= 0) + #expect(card.frame.maxX <= width) + // The card keeps a margin inside the pane at every width, so it + // can never be a fixed-width dialog that overflows a narrow one. + #expect(card.frame.width <= width - 24) + #expect(card.frame.height > 0) + #expect(card.frame.minY >= 0) + #expect(card.frame.maxY <= overlay.bounds.height) + for hosted in Self.descendants(of: card) where !hosted.isHidden { + let rect = hosted.convert(hosted.bounds, to: overlay) + #expect(rect.minX >= 0 && rect.maxX <= width) + } } + // …and it genuinely tracks the pane rather than clamping to one size. + let narrow = cardWidthsByPaneWidth[190] ?? 0 + let medium = cardWidthsByPaneWidth[320] ?? 0 + let wide = cardWidthsByPaneWidth[720] ?? 0 + #expect(narrow > 0) + #expect(narrow < medium) + #expect(medium <= wide) } + var reconnects = 0 overlay.onReconnect = { reconnects += 1 } - let reconnect = try #require(stack.arrangedSubviews.compactMap { $0 as? NSButton }.first) - reconnect.performClick(nil) + // `reconnectAction` is the closure the overlay hands to the card's Retry + // control, so invoking it exercises the same wiring the click does. + let reconnect = try #require(overlay.reconnectAction) + reconnect() + #expect(reconnects == 1) + overlay.apply(.init( + title: "Connecting", detail: "Waiting", + showsProgress: true, showsReconnectButton: false + )) + let stillOffersReconnect = overlay.reconnectAction != nil + #expect(!stillOffersReconnect) #expect(reconnects == 1) } diff --git a/cmuxTests/CloudManualMirrorTransportTests.swift b/cmuxTests/CloudManualMirrorTransportTests.swift index 680a33bd9b61..e3446c570b48 100644 --- a/cmuxTests/CloudManualMirrorTransportTests.swift +++ b/cmuxTests/CloudManualMirrorTransportTests.swift @@ -19,7 +19,8 @@ struct CloudManualMirrorTransportTests { reference: "operation=op trace=trace" ) - #expect(presentation.title == "Cloud terminal could not start") + // The pane-centered failure card (9bf6cb8c94) uses the short title. + #expect(presentation.title == "Couldn’t open terminal") #expect(presentation.detail == "The Cloud terminal endpoint was unavailable.") #expect(!presentation.showsProgress) #expect(presentation.showsReconnectButton) diff --git a/cmuxTests/CloudNameAuthorityFixture.swift b/cmuxTests/CloudNameAuthorityFixture.swift index 8bc372c8bf36..b3591b0a6f40 100644 --- a/cmuxTests/CloudNameAuthorityFixture.swift +++ b/cmuxTests/CloudNameAuthorityFixture.swift @@ -65,7 +65,8 @@ final class CloudNameAuthorityFixture { try await settle() } - func expectParity(_ name: String, workspaceName: String? = nil) throws { + func expectParity(_ name: String, workspaceName: String? = nil, + sidebarName: String? = nil, sidebarWorkspaceName: String? = nil) throws { let native = try #require(workspace.surfaceIdFromPanelId(panelID)) #expect(workspace.bonsplitController.tab(native)?.title == name) let nodes = CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( @@ -77,10 +78,10 @@ final class CloudNameAuthorityFixture { } return false }) - #expect(row.searchableTitle == name) + #expect(row.searchableTitle == (sidebarName ?? name)) if let workspaceName { #expect(workspace.title == workspaceName) - #expect(nodes.first { $0.id == CloudTreeNodeBuilder.nodeID(workspace: "a", machine: provider.machine) }?.searchableTitle == workspaceName) + #expect(nodes.first { $0.id == CloudTreeNodeBuilder.nodeID(workspace: "a", machine: provider.machine) }?.searchableTitle == (sidebarWorkspaceName ?? workspaceName)) } } } diff --git a/cmuxTests/CloudNameAuthorityTests.swift b/cmuxTests/CloudNameAuthorityTests.swift index 89dc28c22b89..f4bf09575a7c 100644 --- a/cmuxTests/CloudNameAuthorityTests.swift +++ b/cmuxTests/CloudNameAuthorityTests.swift @@ -128,7 +128,7 @@ extension SetAutoTitleSocketTests { try fixture.expectParity("Agent after clear") } } - @Test("An unacknowledged user rename keeps layout/sidebar parity and rejects a racing auto result") + @Test("An unacknowledged user rename projects in the sidebar and rejects a racing auto result") func cloudPendingNameParity() async throws { try await withCloudNameFixture { fixture in let context = try #require(fixture.catalog.cloudAgentNameContext(workspaceID: fixture.workspace.id, panelID: fixture.panelID)) @@ -137,12 +137,12 @@ extension SetAutoTitleSocketTests { for await _ in gate.stream { break } } #expect(fixture.workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Chosen name")) - try fixture.expectParity("terminal") + try fixture.expectParity("terminal", sidebarName: "Chosen name") let response = try await fixture.call("surface.sync_codex_native_title", extra: [ "title": "Racing agent", "cloud_name_context": try #require(context.wire) ]) #expect(response["applied"] as? Bool == false) - try fixture.expectParity("terminal") + try fixture.expectParity("terminal", sidebarName: "Chosen name") gate.continuation.yield(()) gate.continuation.finish() try await fixture.settle() @@ -182,7 +182,8 @@ extension SetAutoTitleSocketTests { fixture.workspace.cloudVMBinding = nil #expect(fixture.manager.setCustomTitle(tabId: fixture.workspace.id, title: "machine: User's exact name")) #expect(fixture.workspace.cloudVMBinding?.remoteWorkspaceID == "a") - try fixture.expectParity("terminal", workspaceName: "Same workspace") + try fixture.expectParity("terminal", workspaceName: "Same workspace", + sidebarWorkspaceName: "machine: User's exact name") try await fixture.settle() try fixture.expectParity("terminal", workspaceName: "machine: User's exact name") } diff --git a/cmuxTests/CloudPaneCreationRetryTests.swift b/cmuxTests/CloudPaneCreationRetryTests.swift index a88b8951f850..763acee26232 100644 --- a/cmuxTests/CloudPaneCreationRetryTests.swift +++ b/cmuxTests/CloudPaneCreationRetryTests.swift @@ -73,6 +73,11 @@ struct CloudPaneCreationRetryTests { store.cancelAll() release.resolve(true) _ = await returned.result + // The coordinator applies its generation fence after `project` returns, + // behind `CloudOperationContext.withPhase`'s recorder await. That await + // can suspend on a cold path, so the fence is not observable + // synchronously; settle before asserting the discard. + await Self.yieldUntil { discarded } #expect(discarded) } @@ -145,6 +150,17 @@ struct CloudPaneCreationRetryTests { #expect(!store.canRetry) } + @MainActor + private static func yieldUntil( + timeout: Duration = .seconds(2), + _ condition: @MainActor () -> Bool + ) async { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + await Task.yield() + } + } + private static func resource() -> SurfaceResource { SurfaceResource( id: SurfaceResourceID(machine: .cloud("retry-fixture"), kind: .terminal, key: "term_created"), diff --git a/cmuxTests/CloudPlacementCoordinatorTests.swift b/cmuxTests/CloudPlacementCoordinatorTests.swift index 188c309236a0..705262eb44e8 100644 --- a/cmuxTests/CloudPlacementCoordinatorTests.swift +++ b/cmuxTests/CloudPlacementCoordinatorTests.swift @@ -41,8 +41,8 @@ struct CloudPlacementCoordinatorTests { } /// A catalog whose local workspace `bound` mirrors `ws_api`; every other local workspace is a viewer. - private static func harness(bound: UUID) -> (SurfaceCatalog, CloudPlacementTestProvider) { - let catalog = SurfaceCatalog(cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { id in + private static func harness(bound: UUID, live: LiveWorkspaceFixture? = nil) -> (SurfaceCatalog, CloudPlacementTestProvider) { + let catalog = SurfaceCatalog(cloudWorkspaceRenameService: live?.renameService ?? CloudWorkspaceRenameService(), cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { id in id == bound ? WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: false, remoteWorkspaceID: "ws_api") : nil }, workspaceExists: { _, remoteID in remoteID == "ws_api" ? true : nil })) let provider = CloudPlacementTestProvider(machine: machine) @@ -383,6 +383,11 @@ struct CloudPlacementCoordinatorTests { // graph install; an empty view list means the move must project a // fresh tab rather than move a stale one. catalog.upsert(term, from: provider) + // The daemon's projection reply is fenced by its mutation cursor + // (`CmuxTuiSnapshotParser.placedTab` requires one). When the lane drains it + // reconciles against the installed graph above, which predates the new tab; + // the cursor is what keeps that older graph from clearing the new tab ID. + provider.projectCursor = CloudVMCursor(generation: "g", revision: 21) catalog.record(SurfaceProjection(resource: term.id, workspaceID: viewer, panelID: panel, remoteWorkspaceID: "ws_main", remoteTabID: "tab_gone")) let state = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: stateSnapshot, machine: Self.machine)) catalog.reconcileCloudRemoteState(machine: Self.machine, state: state, observation: .current) @@ -394,8 +399,10 @@ struct CloudPlacementCoordinatorTests { } @Test func openingIntoABoundWorkspaceUsesTheSharedPlacementPath() async throws { - let bound = UUID() - let (catalog, provider) = Self.harness(bound: bound) + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let bound = live.id() + let (catalog, provider) = Self.harness(bound: bound, live: live) let term = Self.terminal("term_1", views: [SurfaceRemoteView(tabID: "tab_1", workspace: Self.main)]) catalog.replaceResources([term], on: Self.machine) _ = try await catalog.project(term.id, into: .tab(workspaceID: bound, paneID: UUID().uuidString, index: nil), focus: false, reuseExisting: false) diff --git a/cmuxTests/CloudPlacementTestProvider.swift b/cmuxTests/CloudPlacementTestProvider.swift index 9d1935b956ae..5131d1598478 100644 --- a/cmuxTests/CloudPlacementTestProvider.swift +++ b/cmuxTests/CloudPlacementTestProvider.swift @@ -19,6 +19,8 @@ final class CloudPlacementTestProvider: SurfaceProvider, SurfacePlacementSyncing var beforeMaterialization: (() async throws -> Void)? var refreshCount = 0 var moveCursor: CloudVMCursor? + /// The daemon cursor a projection reply carries. The real reply always has one. + var projectCursor: CloudVMCursor? var workspaceRenames: [String] = [] var tabRenames: [String] = [] @@ -62,7 +64,7 @@ final class CloudPlacementTestProvider: SurfaceProvider, SurfacePlacementSyncing func projectTerminal(_ id: SurfaceResourceID, intoRemoteWorkspace remoteWorkspaceID: String) async throws -> SurfaceRemotePlacement { try await beforeMutation?() projected.append((id.key, remoteWorkspaceID)) - return SurfaceRemotePlacement(workspaceID: remoteWorkspaceID, tabID: "tab_projected") + return SurfaceRemotePlacement(workspaceID: remoteWorkspaceID, tabID: "tab_projected", cursor: projectCursor) } func closeRemoteTab(id: String, inRemoteWorkspace remoteWorkspaceID: String) async throws { events.append("close:" + id) diff --git a/cmuxTests/CloudPortOpenRegressionTests.swift b/cmuxTests/CloudPortOpenRegressionTests.swift index 5f36a0a90950..4c93a67f5d91 100644 --- a/cmuxTests/CloudPortOpenRegressionTests.swift +++ b/cmuxTests/CloudPortOpenRegressionTests.swift @@ -286,10 +286,20 @@ struct CloudPortOpenRegressionTests { LISTEN 0 128 0.0.0.0:6901 0.0.0.0:* LISTEN 0 128 0.0.0.0:3000 0.0.0.0:* """ + let scan = VMExecResult(exitCode: 0, stdout: bindings, stderr: "") + // #13196 (178d35e5da) scoped the RFB/noVNC range to machines whose + // display catalog owns it; SSH and the daemon are always filtered. #expect(CmuxTuiSurfaceProvider.ports( - from: VMExecResult(exitCode: 0, stdout: bindings, stderr: ""), - privateAddress: "10.16.179.6" + from: scan, + privateAddress: "10.16.179.6", + displayPortsOwned: true ) == [3000]) + // Without a desktop, 6901 is an ordinary reachable listener; the + // loopback-only 5901 is still unreachable over the private address. + #expect(CmuxTuiSurfaceProvider.ports( + from: scan, + privateAddress: "10.16.179.6" + ) == [3000, 6901]) } @Test("Unavailable scans retain ports while an authoritative empty scan retires them") @@ -380,14 +390,16 @@ struct CloudPortOpenRegressionTests { @Test("Sidebar and repeated opens use the machine-owned local workspace and one catalog identity") func rowOpenUsesSharedCatalogPath() async throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine, supportsPortPreviews: true) catalog.register(provider) let port = discoveredPort(8000, in: workspace) catalog.replaceResources([terminal(), port], on: machine, info: machineInfo(workspaces: [workspace])) - let ownerWorkspaceID = UUID() - let unrelatedWorkspaceID = UUID() + let ownerWorkspaceID = live.id() + let unrelatedWorkspaceID = live.id() _ = try await catalog.project( terminal().id, into: .workspace(id: ownerWorkspaceID, placement: .split), @@ -464,7 +476,10 @@ struct CloudPortOpenRegressionTests { @Test("Unsupported providers fail before a synthetic row or browser pane is created") func unsupportedProviderFailsClosed() async { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + let destination = live.id() let provider = FakeProvider(machine: machine, supportsPortPreviews: false) catalog.register(provider) @@ -474,7 +489,7 @@ struct CloudPortOpenRegressionTests { try await catalog.openCloudPort( machine: machine, port: 8000, - into: .workspace(id: UUID(), placement: .split), + into: .workspace(id: destination, placement: .split), focus: false, reuseExisting: false ) diff --git a/cmuxTests/CloudPortRoutePlanTests.swift b/cmuxTests/CloudPortRoutePlanTests.swift index 9b88174ba4fb..362fccace26c 100644 --- a/cmuxTests/CloudPortRoutePlanTests.swift +++ b/cmuxTests/CloudPortRoutePlanTests.swift @@ -205,7 +205,9 @@ struct CloudPortRoutePlanTests { @Test("Cloud browser opening starts app-owned access without a system VPN") func cloudBrowserStartsUserspaceAccess() async throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let links = CloudMachineLinkManager(clientURL: nil, hostThemeColors: { nil }) let provider = CmuxTuiSurfaceProvider( summary: VMSummary(id: "vm-userspace", provider: "freestyle", status: "running", image: "fixture", createdAt: 0, base: nil, addressIPv4: "10.16.0.7"), @@ -213,7 +215,7 @@ struct CloudPortRoutePlanTests { catalog: catalog ) catalog.register(provider) - let panel = BrowserPanel(workspaceId: UUID(), websiteDataStore: .nonPersistent()) + let panel = BrowserPanel(workspaceId: live.id(), websiteDataStore: .nonPersistent()) defer { panel.close() } provider.configureBrowser(panel, url: URL(string: "http://10.16.0.7:8000/path?q=1#fragment")!) let model = try #require(panel.cloudAccess.model) diff --git a/cmuxTests/CloudSidebarConsistencyTests.swift b/cmuxTests/CloudSidebarConsistencyTests.swift index 2ce00b774346..07e4cfba7d3e 100644 --- a/cmuxTests/CloudSidebarConsistencyTests.swift +++ b/cmuxTests/CloudSidebarConsistencyTests.swift @@ -89,14 +89,16 @@ struct CloudSidebarConsistencyTests { @Test("Opening a captured sidebar row uses current membership and current workspace name") func openingStaleRow() async throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) install(try state(tabs: ["a"]), in: catalog) let captured = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: "ws_main") install(try state(revision: 2, name: "Renamed", tabs: ["b", "c"]), in: catalog) var titles: [String] = [] - let workspaceID = UUID() + let workspaceID = live.id() let host = SurfaceCatalog.NewWorkspaceHost( create: { title in titles.append(title); return (workspaceID, nil) }, paneLookup: { _, _ in "pane" }, closeStarter: { _, _ in } @@ -111,12 +113,14 @@ struct CloudSidebarConsistencyTests { @Test("Opening a tab selection does not expand it to the entire Cloud workspace") func selectedTabsRemainASelection() async throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) catalog.register(CloudPlacementTestProvider(machine: machine)) install(try state(), in: catalog) let all = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: "ws_main") let selection = SurfaceResourceGroup(title: "Selection", placements: [all.placements[0]], remoteWorkspaceID: "ws_main") - let workspaceID = UUID() + let workspaceID = live.id() let host = SurfaceCatalog.NewWorkspaceHost( create: { _ in (workspaceID, nil) }, paneLookup: { _, _ in "pane" }, closeStarter: { _, _ in } ) diff --git a/cmuxTests/CloudSidebarNativeDropTests.swift b/cmuxTests/CloudSidebarNativeDropTests.swift index 206271af2a6a..b9ed21032dc7 100644 --- a/cmuxTests/CloudSidebarNativeDropTests.swift +++ b/cmuxTests/CloudSidebarNativeDropTests.swift @@ -11,9 +11,12 @@ import Testing @Suite("Cloud folder native drops") struct CloudSidebarNativeDropTests { @Test("A projection-capable terminal can reorder inside its parent without opening a pane") - func terminalOrganizationKeepsProjectionCapability() throws { - let fixture = CloudSidebarOrderingFixture() - defer { fixture.close() } + func terminalOrganizationKeepsProjectionCapability() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let app = try VaultPaneAppFixture() + defer { app.tearDown() } + let fixture = CloudSidebarOrderingFixture(transferRegistry: app.appDelegate.tabDragTransferRegistry) + defer { fixture.close() } let snapshot = fixture.snapshot() var resource = snapshot.resources[0] let originalView = try #require(resource.remoteViews?.first) @@ -38,13 +41,15 @@ struct CloudSidebarNativeDropTests { let session = CloudSidebarDraggingSession(pasteboard: board) coordinator.outlineView(outline, draggingSession: session, willBeginAt: .zero, forItems: [source]) coordinator.outlineView(outline, draggingSession: session, willBeginAt: .zero, forItems: [source]) - let info = CloudSidebarDraggingInfo(source: outline, pasteboard: board, location: .zero) + let info = CloudSidebarDraggingInfo(source: outline, pasteboard: board, location: .zero, + sequenceNumber: session.draggingSequenceNumber) #expect(coordinator.outlineView(outline, validateDrop: info, proposedItem: parent, proposedChildIndex: 0) == .move) #expect(coordinator.outlineView(outline, acceptDrop: info, item: parent, childIndex: 0)) #expect(parent.children.map(\.id) == Array(ids.reversed())) #expect(fixture.provider.moved.isEmpty && fixture.provider.projected.isEmpty) coordinator.outlineView(outline, draggingSession: session, endedAt: .zero, operation: .move) #expect(fixture.transferRegistry.resolve(from: board) == nil) + } } @Test("Cloud reorder draws the left sidebar line and clears rejected and exited destinations") diff --git a/cmuxTests/CloudSidebarOrderingTests.swift b/cmuxTests/CloudSidebarOrderingTests.swift index 36fe2b796da4..03c51a07dafd 100644 --- a/cmuxTests/CloudSidebarOrderingTests.swift +++ b/cmuxTests/CloudSidebarOrderingTests.swift @@ -180,14 +180,15 @@ final class CloudSidebarOrderingFixture { let defaultsName = "cloud-sidebar-ordering-\(UUID().uuidString)" let catalog: SurfaceCatalog let provider: CloudPlacementTestProvider - let transferRegistry = TabDragTransferRegistry() + let transferRegistry: TabDragTransferRegistry let coordinator: CloudTreeOutlineView.Coordinator let container: CloudTreeContainerView let window: NSWindow - init() { + init(transferRegistry: TabDragTransferRegistry? = nil) { defaults = UserDefaults(suiteName: defaultsName)! provider = CloudPlacementTestProvider(machine: machine) + self.transferRegistry = transferRegistry ?? TabDragTransferRegistry() catalog = SurfaceCatalog(sidebarOrganization: CloudSidebarOrganizationStore(defaults: defaults)) let catalog = catalog coordinator = CloudTreeOutlineView.Coordinator( diff --git a/cmuxTests/CloudSidebarRenameFixture.swift b/cmuxTests/CloudSidebarRenameFixture.swift index 2834ae958222..03f3fdb3a862 100644 --- a/cmuxTests/CloudSidebarRenameFixture.swift +++ b/cmuxTests/CloudSidebarRenameFixture.swift @@ -69,6 +69,19 @@ final class CloudSidebarRenameFixture { try await catalog.cloudRenameCoordinator.enqueue(key: .workspace(machine: machine, id: "barrier"), pendingName: "") {}.value } + @discardableResult + func agentName(_ name: String) -> Bool { + guard let context = catalog.cloudAgentNameContext(workspaceID: workspace.id, panelID: panelID) else { return false } + return catalog.submitCloudPanelRename( + workspace: workspace, panelID: panelID, title: name, source: .auto, context: context + ) == true + } + + @discardableResult + func userName(_ name: String) -> Bool { + catalog.submitCloudPanelRename(workspace: workspace, panelID: panelID, title: name, source: .user) == true + } + func assertParity(_ title: String, workspaceName: String = "Fixture workspace") throws { let native = try #require(workspace.surfaceIdFromPanelId(panelID)) #expect(workspace.bonsplitController.tab(native)?.title == title) diff --git a/cmuxTests/CloudSidebarRenameReconciliationTests.swift b/cmuxTests/CloudSidebarRenameReconciliationTests.swift index 1ff32acce2e8..0365e6d91e3b 100644 --- a/cmuxTests/CloudSidebarRenameReconciliationTests.swift +++ b/cmuxTests/CloudSidebarRenameReconciliationTests.swift @@ -11,26 +11,28 @@ import Testing struct CloudSidebarRenameReconciliationTests { private struct Rejected: Error {} - @Test("An RPC receipt protects agent names until an accepted graph catches up") + @Test("An RPC receipt projects the sidebar name until an accepted graph updates the native title") func receiptProtectsAgentName() async throws { let fixture = try makeFixture() defer { fixture.close() } let workspace = fixture.workspace - #expect(fixture.manager.syncAgentTerminalTitle(tabId: workspace.id, panelId: fixture.panelID, - title: "Calculate 2+2", catalog: fixture.catalog)) + #expect(fixture.agentName("Calculate 2+2")) try await fixture.drain() let receipt = CloudVMPendingMutation(kind: .tabRename, remoteTabID: "tab_main", name: "Calculate 2+2", receipt: .init(generation: "fixture", revision: 5)) let stale = try fixture.state(revision: 2) fixture.install(stale, observation: .init(freshness: .current, reason: nil, pendingWrites: [receipt])) fixture.reconcile() - #expect(workspace.panelTitle(panelId: fixture.panelID) == "Calculate 2+2") - #expect(workspace.panelCustomTitleSources[fixture.panelID] == .auto) - fixture.install(try fixture.state(revision: 5, name: "Calculate 2+2")) + #expect(workspace.panelTitle(panelId: fixture.panelID) == "terminal") + #expect(workspace.panelCustomTitleSources[fixture.panelID] == nil) + #expect(fixture.catalog.snapshot.resources.first { $0.id == fixture.resourceID }? + .remoteViews?.first { $0.tabID == "tab_main" }?.name == "Calculate 2+2") + #expect(fixture.catalog.cloudStates[fixture.machine]?.lookupIndex.tab(id: "tab_main")?.name == nil) + fixture.install(try fixture.state(revision: 5, name: "Calculate 2+2", nameSource: "auto")) fixture.reconcile() fixture.service.reconcileRemoteState(machine: fixture.machine, state: stale, catalog: fixture.catalog, observation: .current) try fixture.assertParity("Calculate 2+2") - #expect(workspace.panelCustomTitleSources[fixture.panelID] == .auto) + #expect(workspace.panelCustomTitleSources[fixture.panelID] == .remote) } @Test("The latest user name survives superseded failures and delayed graph callbacks") @@ -43,32 +45,33 @@ struct CloudSidebarRenameReconciliationTests { attempt += 1 if attempt == 1 { throw Rejected() } } - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Same label", catalog: fixture.catalog)) - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Same label", catalog: fixture.catalog)) + #expect(fixture.userName("Same label")) + #expect(fixture.userName("Same label")) try await fixture.drain() - #expect(workspace.panelCustomTitles[fixture.panelID] == "Same label") - #expect(workspace.panelCustomTitleSources[fixture.panelID] == .user) + #expect(workspace.panelCustomTitles[fixture.panelID] == nil) + #expect(workspace.panelCustomTitleSources[fixture.panelID] == nil) #expect(fixture.provider.tabRenames == ["Same label"]) let old = try fixture.state() - fixture.install(try fixture.state(revision: 3, name: "Same label")) + fixture.install(try fixture.state(revision: 3, name: "Same label", nameSource: "user")) fixture.reconcile() fixture.service.reconcileRemoteState(machine: fixture.machine, state: old, catalog: fixture.catalog, observation: .current) try fixture.assertParity("Same label") } - @Test("Failed agent rename restores the previous automatic title and ownership") + @Test("Failed agent rename keeps the accepted title and daemon naming authority") func failedAgentRenameRollsBack() async throws { let fixture = try makeFixture() defer { fixture.close() } let workspace = fixture.workspace - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Earlier task", source: .auto, catalog: fixture.catalog)) + #expect(fixture.agentName("Earlier task")) try await fixture.drain() - fixture.install(try fixture.state(revision: 2, name: "Earlier task")) + fixture.install(try fixture.state(revision: 2, name: "Earlier task", nameSource: "auto")) fixture.reconcile() fixture.provider.beforeMutation = { throw Rejected() } - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Failed task", source: .auto, catalog: fixture.catalog)) + #expect(fixture.agentName("Failed task")) try await fixture.drain() - #expect(workspace.panelCustomTitleSources[fixture.panelID] == .auto) + #expect(workspace.panelCustomTitleSources[fixture.panelID] == .remote) + #expect(fixture.catalog.cloudStates[fixture.machine]?.lookupIndex.tab(id: "tab_main")?.nameAuthority?.source == .auto) try fixture.assertParity("Earlier task") } @@ -77,7 +80,7 @@ struct CloudSidebarRenameReconciliationTests { let fixture = try makeFixture() defer { fixture.close() } fixture.provider.beforeMutation = { - fixture.install(try fixture.state(revision: 2, name: "Remote user label")) + fixture.install(try fixture.state(revision: 2, name: "Remote user label", nameSource: "user")) fixture.reconcile() throw Rejected() } @@ -93,8 +96,8 @@ struct CloudSidebarRenameReconciliationTests { let fixture = try makeFixture() defer { fixture.close() } fixture.provider.beforeMutation = { throw Rejected() } - #expect(fixture.workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Failed first", catalog: fixture.catalog)) - #expect(fixture.workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Failed second", catalog: fixture.catalog)) + #expect(fixture.userName("Failed first")) + #expect(fixture.userName("Failed second")) try await fixture.drain() #expect(fixture.workspace.panelCustomTitles[fixture.panelID] == nil) #expect(fixture.workspace.panelTitle(panelId: fixture.panelID) == "terminal") @@ -104,11 +107,10 @@ struct CloudSidebarRenameReconciliationTests { func titlePersistence(userOwned: Bool) async throws { let fixture = try makeFixture() defer { fixture.close() } - let source: Workspace.CustomTitleSource = userOwned ? .user : .auto let title = "Build / 東京 🚀" - #expect(fixture.workspace.setPanelCustomTitle(panelId: fixture.panelID, title: title, source: source, catalog: fixture.catalog)) + #expect(userOwned ? fixture.userName(title) : fixture.agentName(title)) try await fixture.drain() - fixture.install(try fixture.state(revision: 2, name: title)) + fixture.install(try fixture.state(revision: 2, name: title, nameSource: userOwned ? "user" : "auto")) fixture.reconcile() let data = try JSONEncoder().encode(fixture.workspace.sessionSnapshot(includeScrollback: false)) let snapshot = try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: data) @@ -117,7 +119,7 @@ struct CloudSidebarRenameReconciliationTests { let restoredPanel = try #require(panels[fixture.panelID]) fixture.manager.tabs = [restored] defer { for panel in restored.panels.values { panel.close() } } - let graph = try fixture.state(generation: "reconnected", name: title) + let graph = try fixture.state(generation: "reconnected", name: title, nameSource: userOwned ? "user" : "auto") // Cross the daemon serialization boundary too, not just a local display copy. let bytes = try JSONSerialization.data(withJSONObject: try #require(graph.snapshotObject())) let object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) @@ -126,27 +128,28 @@ struct CloudSidebarRenameReconciliationTests { panelID: restoredPanel, remoteWorkspaceID: "ws_main", remoteTabID: "tab_main")) fixture.reconcile() #expect(restored.panelTitle(panelId: restoredPanel) == title) - #expect(restored.panelCustomTitleSources[restoredPanel] == source) + #expect(restored.panelCustomTitleSources[restoredPanel] == .remote) #expect(restored.cloudVMBinding?.remoteWorkspaceID == "ws_main") #expect(fixture.catalog.cloudStates[fixture.machine]?.lookupIndex.tab(id: "tab_main")?.name == title) + #expect(fixture.catalog.cloudStates[fixture.machine]?.lookupIndex.tab(id: "tab_main")?.nameAuthority?.source == (userOwned ? .user : .auto)) } @Test("A stale peer cannot auto-rename an explicitly named shared placement") func peerUserOwnershipBlocksAutoRename() async throws { let fixture = try makeFixture() defer { fixture.close() } - let workspace = fixture.workspace - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Human label", catalog: fixture.catalog)) + #expect(fixture.userName("Human label")) + try await fixture.drain() + fixture.install(try fixture.state(revision: 2, name: "Human label", nameSource: "user")) + fixture.reconcile() let peer = Workspace() fixture.manager.tabs.append(peer) defer { for panel in peer.panels.values { panel.close() } } let panel = try #require(peer.focusedPanelId) fixture.catalog.record(SurfaceProjection(resource: fixture.resourceID, workspaceID: peer.id, panelID: panel, remoteWorkspaceID: "ws_main", remoteTabID: "tab_main")) - #expect(!peer.setPanelCustomTitle(panelId: panel, title: "Delayed agent", source: .auto, catalog: fixture.catalog)) - try await fixture.drain() - fixture.install(try fixture.state(revision: 2, name: "Human label")) - fixture.reconcile() + #expect(!peer.setPanelCustomTitle(panelId: panel, title: "Delayed agent", source: .auto, + catalog: fixture.catalog)) #expect(peer.panelTitle(panelId: panel) == "Human label") #expect(fixture.provider.tabRenames == ["Human label"]) } @@ -155,10 +158,9 @@ struct CloudSidebarRenameReconciliationTests { func remotePeerConfirmationRetainsAgentOwnership() async throws { let fixture = try makeFixture() defer { fixture.close() } - let workspace = fixture.workspace - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "First task", source: .auto, catalog: fixture.catalog)) + #expect(fixture.agentName("First task")) try await fixture.drain() - fixture.install(try fixture.state(revision: 2, name: "First task")) + fixture.install(try fixture.state(revision: 2, name: "First task", nameSource: "auto")) let peer = Workspace() fixture.manager.tabs.append(peer) defer { for panel in peer.panels.values { panel.close() } } @@ -167,9 +169,9 @@ struct CloudSidebarRenameReconciliationTests { panelID: panel, remoteWorkspaceID: "ws_main", remoteTabID: "tab_main")) fixture.reconcile() #expect(peer.panelCustomTitleSources[panel] == .remote) - #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Second task", source: .auto, catalog: fixture.catalog)) + #expect(fixture.agentName("Second task")) try await fixture.drain() - fixture.install(try fixture.state(revision: 3, name: "Second task")) + fixture.install(try fixture.state(revision: 3, name: "Second task", nameSource: "auto")) fixture.reconcile() #expect(peer.panelTitle(panelId: panel) == "Second task") try fixture.assertParity("Second task") diff --git a/cmuxTests/CloudSurfaceMoveOwnershipTests.swift b/cmuxTests/CloudSurfaceMoveOwnershipTests.swift index c8891d998005..81803505d564 100644 --- a/cmuxTests/CloudSurfaceMoveOwnershipTests.swift +++ b/cmuxTests/CloudSurfaceMoveOwnershipTests.swift @@ -243,7 +243,9 @@ struct CloudSurfaceMoveOwnershipTests { let workspace = Workspace() defer { workspace.teardownAllPanels() } let panelID = try #require(workspace.focusedPanelId) - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + live.register(workspace) + let catalog = SurfaceCatalog(live: live) let id = SurfaceResourceID(machine: .cloud("a"), kind: .display, key: "offline") catalog.restore([SurfaceProjectionRecord(panelID: panelID, resource: id)], workspaceID: workspace.id) #expect(workspace.machineOwningSurface(panelID, catalog: catalog) == .cloud("a")) diff --git a/cmuxTests/CloudTerminalPlacementTests.swift b/cmuxTests/CloudTerminalPlacementTests.swift index 9d72003b99da..89a1abdc4281 100644 --- a/cmuxTests/CloudTerminalPlacementTests.swift +++ b/cmuxTests/CloudTerminalPlacementTests.swift @@ -365,11 +365,13 @@ struct CloudTerminalPlacementTests { @Test("An empty local sidebar group does not require a remote workspace") func emptyLocalGroupRetainsLocalBehavior() async throws { try await AppContextSerialGate.withExclusiveAppContext { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let provider = CloudTerminalPlacementTestProvider(machine: .local, catalog: catalog) catalog.register(provider) defer { provider.release.resolve(true); catalog.unregister(machine: .local) } - let originalDestination = UUID() + let originalDestination = live.id() var selectedDestination = originalDestination var failures: [String] = [] var finished = false diff --git a/cmuxTests/CloudTreeCompactLayoutTests.swift b/cmuxTests/CloudTreeCompactLayoutTests.swift index beee64126a42..dd8f98666750 100644 --- a/cmuxTests/CloudTreeCompactLayoutTests.swift +++ b/cmuxTests/CloudTreeCompactLayoutTests.swift @@ -146,8 +146,21 @@ struct CloudTreeCompactLayoutTests { let starts = try nodes.map { node in let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true)) let ink = try inkColumns(in: cell) - try #require(ink.runs.count >= 2) - return CGFloat(ink.runs[1].lowerBound) / ink.scale + // A hollow glyph can contain several disconnected ink-column runs. + // Locate title ink beyond the rendered icon, rather than assuming + // the second run belongs to the title. + let iconBounds = try #require( + descendants(of: cell).compactMap { view -> CGRect? in + guard view is CmuxResolvedIconImageView else { return nil } + return cell.convert(view.bounds, from: view) + }.min(by: { $0.minX < $1.minX }), + "Expected an appearance-resolved row icon" + ) + let titleRun = try #require( + ink.runs.first { CGFloat($0.lowerBound) / ink.scale >= iconBounds.maxX }, + "Expected title ink after the row icon" + ) + return CGFloat(titleRun.lowerBound) / ink.scale } // Sections insets the whole machine identity 6pt inside its band. // Preserve that decoration while comparing the shared icon column. diff --git a/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift b/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift index a0f094475f95..6c6852b4bf36 100644 --- a/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift +++ b/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift @@ -18,8 +18,9 @@ private struct CloudTreeCLIResult { /// machine — its four groups, in this order: **Workspaces** (the machine's /// face, always its own row with its own "+", one row per workspace with the /// terminals in its layout), **Ports**, **Displays** (one row per screen), -/// and last, its own section, **Terminals** (every terminal the machine owns, -/// one row per identity, always present so its "+" is New Terminal). +/// **Terminals** (every terminal the machine owns, one row per identity, +/// always present so its "+" is New Terminal), and last the display-only +/// **Resources** readings (added by #12740). /// /// Regression (https://github.com/manaflow-ai/cmux/issues/11762): a machine /// with a single workspace used to fold the group into the workspace row @@ -108,6 +109,15 @@ struct CloudTreeOneMachineManyWorkspacesTests { return resource } + /// The machine's final section since #12740: display-only readings, one row per metric. + private let resourcesSection = [ + "machine:brave-otter/resources", + "machine:brave-otter/resources/cpu", + "machine:brave-otter/resources/memory", + "machine:brave-otter/resources/disk", + "machine:brave-otter/resources/usage", + ] + private func rows(_ snapshot: SurfaceCatalogSnapshot) -> [CloudTreeNode] { CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( machines: [fleetRow()], snapshot: snapshot, localWorkspaces: [], includeLocalMachine: false @@ -208,7 +218,7 @@ struct CloudTreeOneMachineManyWorkspacesTests { "machine:brave-otter/displays/placeholder", "machine:brave-otter/terminals", "resource:brave-otter/terminal/term_1", - ], "the group is its own row above the lone workspace — never folded into it") + ] + resourcesSection, "the group is its own row above the lone workspace — never folded into it") let group = try #require(tree.first { $0.id == "machine:brave-otter/workspaces" }) #expect(group.structureTag == "workspacesGroup") #expect(CloudTreeRowHoverButtons.hasButtons(for: group.kind), "the group's hover + (New Workspace) stays reachable on a one-workspace machine") @@ -217,7 +227,7 @@ struct CloudTreeOneMachineManyWorkspacesTests { #expect(!row.isMachineRow, "a workspace is a row under its machine, never a machine of its own") } - @Test("Under a connected machine: Workspaces, Ports, Displays, then Terminals (every terminal) as the last section") + @Test("Under a connected machine: Workspaces, Ports, Displays, Terminals (every terminal), then Resources last") func workspacesPortsDisplaysThenTerminals() throws { let main = workspace("ws_main", "main", index: 0, focused: true) let side = workspace("ws_side", "side", index: 1) @@ -257,7 +267,7 @@ struct CloudTreeOneMachineManyWorkspacesTests { "resource:brave-otter/terminal/term_2", "resource:brave-otter/terminal/term_3", "resource:brave-otter/terminal/term_shared", - ], "the machine's four groups in order, Terminals last; a daemon browser in no workspace gets no group of its own") + ] + resourcesSection, "the machine's groups in order, Resources after Terminals; a daemon browser in no workspace gets no group of its own") let byID = Dictionary(tree.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) // Terminals lists every terminal the machine owns, one row per identity — the // workspace rows are pointers into it — badged with its daemon-tab count. @@ -287,7 +297,8 @@ struct CloudTreeOneMachineManyWorkspacesTests { ]) // Displays is one row per screen; the group is searchable under that name. #expect(byID["machine:brave-otter/displays"]?.searchableTitle == "Displays") - #expect(tree.last?.id == "resource:brave-otter/terminal/term_shared", "Terminals is the machine's last section") + #expect(tree.dropLast(resourcesSection.count).last?.id == "resource:brave-otter/terminal/term_shared", "Terminals is the last pane section") + #expect(tree.last?.id == "machine:brave-otter/resources/usage", "Resources readings close the machine") } @Test("An empty machine still offers Workspaces, Ports, Displays, and Terminals") @@ -304,7 +315,7 @@ struct CloudTreeOneMachineManyWorkspacesTests { "machine:brave-otter/displays/placeholder", "machine:brave-otter/terminals", "machine:brave-otter/terminals/placeholder", - ]) + ] + resourcesSection) for groupID in ["machine:brave-otter/workspaces", "machine:brave-otter/terminals"] { let group = try #require(tree.first { $0.id == groupID }) #expect(CloudTreeRowHoverButtons.hasButtons(for: group.kind), "\(groupID) keeps its hover +") @@ -635,7 +646,7 @@ struct CloudTreeOneMachineManyWorkspacesTests { "resource:brave-otter/terminal/term_1", "resource:brave-otter/terminal/term_bg", "resource:brave-otter/terminal/term_2", - ], "no row for it under any workspace; one row for it in Terminals, like every other terminal") + ] + resourcesSection, "no row for it under any workspace; one row for it in Terminals, like every other terminal") let byID = Dictionary(tree.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) guard case .terminal(let poolRow) = try #require(byID["resource:brave-otter/terminal/term_bg"]).kind, case .terminal(let viewedPoolRow) = try #require(byID["resource:brave-otter/terminal/term_1"]).kind, @@ -670,13 +681,13 @@ struct CloudTreeOneMachineManyWorkspacesTests { ) let snapshot = SurfaceCatalogSnapshot(machines: [info(workspaces: [main], hasDesktop: true)], resources: [first, second], projections: []) let tree = rows(snapshot) - #expect(tree.map(\.id).suffix(5) == [ + #expect(Array(tree.map(\.id).suffix(5 + resourcesSection.count)) == [ "machine:brave-otter/displays", "resource:brave-otter/display/display:1", "resource:brave-otter/display/display:2", "machine:brave-otter/terminals", "machine:brave-otter/terminals/placeholder", - ], "the screens sit above the Terminals section") + ] + resourcesSection, "the screens sit above the Terminals and Resources sections") guard case .displaysPool(_, let count, _) = try #require(tree.first { $0.id == "machine:brave-otter/displays" }).kind else { Issue.record("expected the Displays group"); return } diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index aec53c27abae..b6818fa77077 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -100,7 +100,9 @@ struct CloudWorkspaceLiveProjectionTests { @Test("Opening a port in a bound Cloud workspace survives reconciliation and follows local moves") func openedPortSurvivesReconciliation() async throws { - let local = UUID(), other = UUID(), viewer = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let local = live.id(), other = live.id(), viewer = UUID() let bindings = [ local: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a"), other: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "b") @@ -110,7 +112,7 @@ struct CloudWorkspaceLiveProjectionTests { bindings: { bindings }, close: { closed.append($0) } )) let placement = CloudPlacementCoordinator(binding: { bindings[$0] }) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: placement, cloudWorkspaceProjectionCoordinator: coordinator) + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: placement, cloudWorkspaceProjectionCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) let port = CmuxTuiSnapshotParser.portBrowser(machine: machine, port: 6969) @@ -142,7 +144,9 @@ struct CloudWorkspaceLiveProjectionTests { @Test("Existing native workspaces follow create, cross-workspace move, one-view close and reconnect") func followsLiveMembership() async throws { - let a = UUID(), b = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let a = live.id(), b = live.id() let bindings = [a: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a"), b: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "b")] var closed: [SurfaceProjection] = [] @@ -150,7 +154,7 @@ struct CloudWorkspaceLiveProjectionTests { let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init( bindings: { bindings }, close: { closed.append($0) }, applyLayout: { id, layout, _ in layouts[id] = layout } )) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { bindings[$0] }), + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { bindings[$0] }), cloudWorkspaceProjectionCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) @@ -217,7 +221,9 @@ struct CloudWorkspaceLiveProjectionTests { @Test("Opening one remote terminal repeatedly reuses its exact local projection") func openingOneTerminalRepeatedlyReusesProjection() async throws { - let workspaceID = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let workspaceID = live.id() let remoteWorkspace = SurfaceRemoteWorkspace( id: "remote-main", name: "main", index: 0, focused: true ) @@ -233,7 +239,7 @@ struct CloudWorkspaceLiveProjectionTests { agent: nil, remoteWorkspace: remoteWorkspace, remoteViews: [remoteView], port: nil, url: nil ) - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) catalog.register(CloudPlacementTestProvider(machine: machine)) catalog.upsert(resource) @@ -256,11 +262,13 @@ struct CloudWorkspaceLiveProjectionTests { @Test("Lifecycle cancellation is not retained as a projection failure") func cancelledMaterializationIsNotAnError() async throws { - let local = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let local = live.id() let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init(bindings: { [local: WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a")] })) - let catalog = SurfaceCatalog(cloudWorkspaceProjectionCoordinator: coordinator) + let catalog = SurfaceCatalog(live: live, cloudWorkspaceProjectionCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) provider.beforeMaterialization = { throw CancellationError() } catalog.register(provider) @@ -272,10 +280,12 @@ struct CloudWorkspaceLiveProjectionTests { @Test("Reconnect does not recreate an explicitly closed daemon view") func reconnectDoesNotUndoRemoteClose() async throws { - let local = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let local = live.id() let binding = WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a") let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init(bindings: { [local: binding] })) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { _ in binding }), + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { _ in binding }), cloudWorkspaceProjectionCoordinator: coordinator) catalog.register(CloudPlacementTestProvider(machine: machine)) install(try graph(["first": "a"], revision: 1), catalog: catalog) @@ -296,10 +306,12 @@ struct CloudWorkspaceLiveProjectionTests { @Test("An acknowledged local close cannot be reopened by a lagging graph") func localCloseReceipt() async throws { - let local = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let local = live.id() let binding = WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: false, remoteWorkspaceID: "a") let coordinator = CloudWorkspaceProjectionCoordinator(environment: .init(bindings: { [local: binding] })) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { _ in binding }), + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: CloudPlacementCoordinator(binding: { _ in binding }), cloudWorkspaceProjectionCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) @@ -320,9 +332,11 @@ struct CloudWorkspaceLiveProjectionTests { @Test("A projection waiting for its Cloud resource remains in the next session snapshot") func pendingProjectionPersistsAcrossAutosave() throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let panel = UUID() - let workspace = UUID() + let workspace = live.id() let resource = SurfaceResourceID(machine: machine, kind: .terminal, key: "term_waiting") let record = SurfaceProjectionRecord( panelID: panel, @@ -339,8 +353,10 @@ struct CloudWorkspaceLiveProjectionTests { @Test("A pending projection has one owner and cannot be resurrected after close or replacement") func pendingProjectionOwnershipIsUnique() throws { - let catalog = SurfaceCatalog() - let firstWorkspace = UUID(), secondWorkspace = UUID(), panel = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) + let firstWorkspace = live.id(), secondWorkspace = live.id(), panel = UUID() let first = SurfaceProjectionRecord( panelID: panel, resource: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_first"), diff --git a/cmuxTests/CloudWorkspaceMembershipTests.swift b/cmuxTests/CloudWorkspaceMembershipTests.swift index d55c247a95aa..0aad91439a15 100644 --- a/cmuxTests/CloudWorkspaceMembershipTests.swift +++ b/cmuxTests/CloudWorkspaceMembershipTests.swift @@ -138,7 +138,9 @@ struct CloudWorkspaceMembershipTests { @Test("Explicit local VNC panes belong only to their current bound workspace") func localDesktopMembership() async throws { - let first = UUID(), second = UUID(), viewer = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let first = live.id(), second = UUID(), viewer = UUID() let coordinator = CloudPlacementCoordinator(binding: { id in guard id == first || id == second else { return nil } return WorkspaceCloudVMBinding( @@ -146,7 +148,7 @@ struct CloudWorkspaceMembershipTests { remoteWorkspaceID: id == first ? "ws_a" : "ws_b" ) }) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: coordinator) + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) let current = try state(desktops: [:]) @@ -198,11 +200,13 @@ struct CloudWorkspaceMembershipTests { @Test("Workspace groups open live local displays and reject a closed placement") func localDisplayGroupOpen() async throws { - let source = UUID(), viewer = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let source = live.id(), viewer = live.id() let coordinator = CloudPlacementCoordinator(binding: { id in id == source ? WorkspaceCloudVMBinding(vmID: "membership-test", isBase: false, remoteWorkspaceID: "ws_a") : nil }) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: coordinator) + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: coordinator) catalog.register(CloudPlacementTestProvider(machine: machine)) publish(try state(desktops: ["desk_b": "b"]), to: catalog) let desktop = SurfaceResourceID(machine: machine, kind: .display, key: "display:1") @@ -226,11 +230,13 @@ struct CloudWorkspaceMembershipTests { @Test("A local VNC pane never adopts or closes another workspace's daemon tab") func localDesktopBesideRemotePlacement() async throws { - let workspace = UUID() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let workspace = live.id() let coordinator = CloudPlacementCoordinator(binding: { _ in WorkspaceCloudVMBinding(vmID: "membership-test", isBase: false, remoteWorkspaceID: "ws_a") }) - let catalog = SurfaceCatalog(cloudPlacementCoordinator: coordinator) + let catalog = SurfaceCatalog(live: live, cloudPlacementCoordinator: coordinator) let provider = CloudPlacementTestProvider(machine: machine) catalog.register(provider) let current = try state(desktops: ["desk_b": "b"]) diff --git a/cmuxTests/CloudWorkspaceTargetingTests.swift b/cmuxTests/CloudWorkspaceTargetingTests.swift index 8b4d2d1c5921..975b274a67db 100644 --- a/cmuxTests/CloudWorkspaceTargetingTests.swift +++ b/cmuxTests/CloudWorkspaceTargetingTests.swift @@ -142,12 +142,20 @@ struct CloudWorkspaceTargetingTests { let manager = fixture.manager let local = try #require(manager.selectedWorkspace) let other = try #require(manager.addWorkspaceIfActive(initialSurface: .cloudVMLoading, select: false)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), styleMask: [.titled], backing: .buffered, defer: false) + // The creation-completion focus rule only applies while the window that + // started the creation is key. The app-host test process runs headless + // and is usually not the active app, so `makeKeyAndOrderFront` does not + // make a programmatic window key; pin key status the way the other + // focus suites do so this exercises the rule instead of the host. + let window = KeyStatusTestWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), styleMask: [.titled], backing: .buffered, defer: false) window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(fixture.windowID.uuidString)") fixture.app.mainWindowContexts.values.first { $0.windowId == fixture.windowID }?.window = window window.makeKeyAndOrderFront(nil) defer { window.orderOut(nil); withExtendedLifetime(window) {} } - #expect(NSApp.keyWindow === window) + // Assert the app's own answer for "which window may a finished creation + // navigate", which is the precondition the selection expectation below + // depends on. + #expect(fixture.app.cloudWorkspaceCreationFocusWindow(windowID: fixture.windowID) === window) let entered = AsyncStream.makeStream() let release = AsyncStream.makeStream() var createdID: UUID? diff --git a/cmuxTests/CmuxEventBusTests.swift b/cmuxTests/CmuxEventBusTests.swift index a8c6fdd609be..38b8590be249 100644 --- a/cmuxTests/CmuxEventBusTests.swift +++ b/cmuxTests/CmuxEventBusTests.swift @@ -283,7 +283,12 @@ final class CmuxEventBusTests: XCTestCase { let growth = after > before ? after - before : 0 XCTAssertLessThan( growth, - UInt64(32 * 1_024 * 1_024), + // JSONSerialization and Foundation's allocator can retain a + // thread-local high-water mark after the autorelease pool drains; + // the bus invariant is the bounded retained snapshot below. Keep + // enough headroom for that allocator cache while still detecting + // an unbounded response retention. + UInt64(64 * 1_024 * 1_024), "publishV2 retained \(growth) bytes after \(iterations) large surface.read_text responses on one socket worker thread" ) XCTAssertTrue(CmuxEventBus.shared.retainedSnapshot().isEmpty) diff --git a/cmuxTests/CmuxTuiSurfaceProviderRegistryPollingTests.swift b/cmuxTests/CmuxTuiSurfaceProviderRegistryPollingTests.swift index b9751eab92c1..cf885eb0fb66 100644 --- a/cmuxTests/CmuxTuiSurfaceProviderRegistryPollingTests.swift +++ b/cmuxTests/CmuxTuiSurfaceProviderRegistryPollingTests.swift @@ -312,6 +312,7 @@ struct CmuxTuiSurfaceProviderRegistryPollingTests { notificationCenter: center ) registry.start(catalog: SurfaceCatalog()) + let refresh = Task { await registry.refresh(force: true) } let began = await received(started) if signOut { await registry.accessDidEnd() @@ -321,6 +322,7 @@ struct CmuxTuiSurfaceProviderRegistryPollingTests { } let stopped = await received(closed) release.resolve(true) + _ = await refresh.value let status = await h.hub.status() await registry.accessDidEnd() diff --git a/cmuxTests/CodexTerminalErrorNotificationTests.swift b/cmuxTests/CodexTerminalErrorNotificationTests.swift index 18867b4ebcb5..32c2466333a0 100644 --- a/cmuxTests/CodexTerminalErrorNotificationTests.swift +++ b/cmuxTests/CodexTerminalErrorNotificationTests.swift @@ -63,10 +63,17 @@ struct CodexTerminalErrorNotificationTests { #expect(!result.timedOut, "\(result.stderr)") #expect(result.status == 0, "\(result.stderr)") #expect( - server.commands.contains { command in - command.contains( - "notify_target \(workspaceID) \(surfaceID) Codex|Error|Selected model is at capacity. Please try a different model." - ) + AgentJournalAppendCapture.captures(in: server.commands).contains { capture in + guard capture.kind == "agent.error.reported", + capture.workspaceId == workspaceID, + capture.surfaceId == surfaceID, + let attention = capture.draft["attention"] as? [String: Any], + let notification = attention["notification"] as? [String: Any] else { + return false + } + return notification["title"] as? String == "Codex" && + notification["subtitle"] as? String == "Error" && + notification["body"] as? String == "Selected model is at capacity. Please try a different model." }, "Expected the nested terminal error to notify, saw \(server.commands)" ) diff --git a/cmuxTests/ComputerUseUXTests.swift b/cmuxTests/ComputerUseUXTests.swift index 7b584128e098..9bd577ea32b2 100644 --- a/cmuxTests/ComputerUseUXTests.swift +++ b/cmuxTests/ComputerUseUXTests.swift @@ -293,7 +293,7 @@ struct ComputerUseUXTests { let responder = try UnixSocketResponder( path: paths.daemonSocketURL.path, - response: #"{\"ok\":true,\"result\":{\"structuredContent\":{\"accessibility\":true,\"screen_recording\":true,\"source\":{\"attribution\":\"helper-daemon\"}}}}"# + response: #"{"ok":true,"result":{"structuredContent":{"accessibility":true,"screen_recording":true,"source":{"attribution":"helper-daemon"}}}}"# ) defer { responder.stop() } diff --git a/cmuxTests/FocusHistoryScopeTests.swift b/cmuxTests/FocusHistoryScopeTests.swift index 240afb2db69c..f4bdca839aaa 100644 --- a/cmuxTests/FocusHistoryScopeTests.swift +++ b/cmuxTests/FocusHistoryScopeTests.swift @@ -20,11 +20,21 @@ struct FocusHistoryScopeTests { #expect(mapping.defaultsKey == SettingCatalog().app.focusHistoryIncludesPanesAndTabs.userDefaultsKey) #expect(CmuxSettingsFileStore.supportedSettingsJSONPaths.contains("app.focusHistoryIncludesPanesAndTabs")) - #expect( - CmuxSettingsFileStore.defaultTemplate().contains( - #"// "focusHistoryIncludesPanesAndTabs" : false,"# - ) + let settingName = "focusHistoryIncludesPanesAndTabs" + let templateLine = try #require( + CmuxSettingsFileStore.defaultTemplate().split(separator: "\n").first { + $0.contains("\"\(settingName)\"") + } + ).trimmingCharacters(in: .whitespaces) + #expect(templateLine.hasPrefix("//")) + var entryJSON = String(templateLine.dropFirst(2)).trimmingCharacters(in: .whitespaces) + if entryJSON.hasSuffix(",") { + entryJSON.removeLast() + } + let entry = try #require( + JSONSerialization.jsonObject(with: Data("{\(entryJSON)}".utf8)) as? [String: Any] ) + #expect(entry[settingName] as? Bool == false) } private func withPaneHistoryManager(_ body: (TabManager) throws -> Void) throws { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d5ce7e0299a8..306b30ade48c 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1552,108 +1552,185 @@ final class WindowChromeSeparatorColorTests: XCTestCase { @MainActor final class WorkspaceChromeColorTests: XCTestCase { - func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 + /// `#112233`, the terminal theme color every case starts from. + private let themeColor = NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 1.0 + ) + + /// `#445566`, standing in for the chrome color the app resolves through + /// `Workspace.resolvedTerminalChromeBackgroundColor`. Passing it through + /// the `chromeBackgroundColor` seam keeps the expectations independent of + /// the test host's ambient `NSColor.windowBackgroundColor`. + private let resolvedChromeColor = NSColor( + srgbRed: 68.0 / 255.0, + green: 85.0 / 255.0, + blue: 102.0 / 255.0, + alpha: 1.0 + ) + + func testBonsplitChromeHexCompositesTranslucentThemeOverWindowBase() { + let hex = Workspace.bonsplitChromeHex(backgroundColor: themeColor, backgroundOpacity: 0.5) + + // Chrome carries the rendered backdrop: the theme composited over the + // window base, with no alpha channel. The window root backdrop owns + // translucency, so an `#RRGGBBAA` hex would apply the opacity twice + // and Bonsplit would derive its tab glyphs from a color nobody sees. + XCTAssertEqual(hex.count, 7, "translucent themes must not leak alpha into chrome hex: \(hex)") + XCTAssertNotEqual(hex, "#112233") + XCTAssertEqual( + hex, + WindowAppearanceSnapshot.compositedTerminalColor( + backgroundColor: themeColor, + opacity: 0.5 + ).hexString() ) - - let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) - XCTAssertEqual(hex, "#1122337F") + assertHex(hex, isBlendOf: themeColor, opacity: 0.5, over: .windowBackgroundColor) } func testBonsplitChromeHexOmitsAlphaWhenOpaque() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 - ) - - let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) + let hex = Workspace.bonsplitChromeHex(backgroundColor: themeColor, backgroundOpacity: 1.0) XCTAssertEqual(hex, "#112233") } - func testBonsplitChromeHexKeepsBackdropWhenSharingWindowBackdrop() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 - ) - + func testBonsplitChromeHexKeepsCompositedBackdropWhenSharingWindowBackdrop() { let hex = Workspace.bonsplitChromeHex( - backgroundColor: color, + backgroundColor: themeColor, backgroundOpacity: 0.5, sharesWindowBackdrop: true ) - XCTAssertEqual(hex, "#1122337F") + + XCTAssertEqual(hex.count, 7) + XCTAssertEqual( + hex, + Workspace.bonsplitChromeHex(backgroundColor: themeColor, backgroundOpacity: 0.5), + "sharing the window backdrop must not change the semantic chrome color" + ) } - func testBonsplitChromeColorsKeepPaneClearWhenTerminalUsesHostLayerBackground() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 + func testBonsplitChromeHexPrefersResolvedChromeBackgroundColor() { + XCTAssertEqual( + Workspace.bonsplitChromeHex( + backgroundColor: themeColor, + backgroundOpacity: 0.5, + chromeBackgroundColor: resolvedChromeColor + ), + "#445566" + ) + // Only the ambient compositing path flattens alpha. A caller that + // hands over a translucent chrome color keeps its alpha in the hex. + XCTAssertEqual( + Workspace.bonsplitChromeHex( + backgroundColor: themeColor, + backgroundOpacity: 0.5, + chromeBackgroundColor: resolvedChromeColor.withAlphaComponent(0.5) + ), + "#4455667F" ) + } + func testBonsplitChromeColorsKeepPaneClearWhenTerminalUsesHostLayerBackground() { let colors = Workspace.bonsplitChromeColors( - backgroundColor: color, + backgroundColor: themeColor, backgroundOpacity: 0.5, - renderingMode: .windowHostBackdrop + renderingMode: .windowHostBackdrop, + chromeBackgroundColor: resolvedChromeColor ) - XCTAssertEqual(colors.backgroundHex, "#1122337F") - XCTAssertEqual(colors.tabBarBackgroundHex, "#1122337F") - XCTAssertEqual(colors.splitButtonBackdropHex, "#1122337F") + XCTAssertEqual(colors.backgroundHex, "#445566") + XCTAssertEqual(colors.tabBarBackgroundHex, "#445566") + XCTAssertEqual(colors.splitButtonBackdropHex, "#445566") XCTAssertEqual(colors.paneBackgroundHex, "#00000000") - } - func testBonsplitChromeColorsKeepSemanticBackgroundButClearLocalBackdropsWhenSharingWindowBackdrop() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 + // Without a pre-resolved chrome color the surfaces fall back to the + // same ambient compositing as `bonsplitChromeHex`. + let ambient = Workspace.bonsplitChromeColors( + backgroundColor: themeColor, + backgroundOpacity: 0.5, + renderingMode: .windowHostBackdrop + ) + XCTAssertEqual( + ambient.backgroundHex, + Workspace.bonsplitChromeHex(backgroundColor: themeColor, backgroundOpacity: 0.5) ) + XCTAssertEqual(ambient.tabBarBackgroundHex, ambient.backgroundHex) + XCTAssertEqual(ambient.splitButtonBackdropHex, ambient.backgroundHex) + XCTAssertEqual(ambient.paneBackgroundHex, "#00000000") + } + func testBonsplitChromeColorsKeepSemanticBackgroundButClearLocalBackdropsWhenSharingWindowBackdrop() { let colors = Workspace.bonsplitChromeColors( - backgroundColor: color, + backgroundColor: themeColor, backgroundOpacity: 0.5, sharesWindowBackdrop: true, - renderingMode: .windowHostBackdrop + renderingMode: .windowHostBackdrop, + chromeBackgroundColor: resolvedChromeColor ) - XCTAssertEqual(colors.backgroundHex, "#1122337F") + XCTAssertEqual(colors.backgroundHex, "#445566") XCTAssertEqual(colors.tabBarBackgroundHex, "#00000000") XCTAssertEqual(colors.splitButtonBackdropHex, "#00000000") XCTAssertEqual(colors.paneBackgroundHex, "#00000000") } func testBonsplitChromeColorsUseConfiguredPaneBorderColor() { - let color = NSColor( - srgbRed: 17.0 / 255.0, - green: 34.0 / 255.0, - blue: 51.0 / 255.0, - alpha: 1.0 - ) - let colors = Workspace.bonsplitChromeColors( - backgroundColor: color, + backgroundColor: themeColor, backgroundOpacity: 0.5, renderingMode: .windowHostBackdrop, - paneBorderColorHex: "#33AAFF" + paneBorderColorHex: "#33AAFF", + chromeBackgroundColor: resolvedChromeColor ) - XCTAssertEqual(colors.backgroundHex, "#1122337F") - XCTAssertEqual(colors.tabBarBackgroundHex, "#1122337F") - XCTAssertEqual(colors.splitButtonBackdropHex, "#1122337F") + XCTAssertEqual(colors.backgroundHex, "#445566") + XCTAssertEqual(colors.tabBarBackgroundHex, "#445566") + XCTAssertEqual(colors.splitButtonBackdropHex, "#445566") XCTAssertEqual(colors.paneBackgroundHex, "#00000000") XCTAssertEqual(colors.borderHex, "#33AAFF") } + + /// Checks an opaque `#RRGGBB` hex against an independent blend of `theme` + /// over `base`, so the composited contract is verified by arithmetic and + /// not only by the resolver under test. `hexString()` truncates channels + /// to whole bytes, so one byte of slack is allowed. + private func assertHex( + _ hex: String, + isBlendOf theme: NSColor, + opacity: CGFloat, + over base: NSColor, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard hex.count == 7, let packed = UInt32(hex.dropFirst(), radix: 16), + let theme = theme.usingColorSpace(.sRGB), + let base = base.usingColorSpace(.sRGB) else { + XCTFail("expected an opaque #RRGGBB chrome hex, got \(hex)", file: file, line: line) + return + } + let actual: [CGFloat] = [ + CGFloat((packed >> 16) & 0xFF), + CGFloat((packed >> 8) & 0xFF), + CGFloat(packed & 0xFF) + ] + let channels: [(KeyPath, String)] = [ + (\.redComponent, "red"), + (\.greenComponent, "green"), + (\.blueComponent, "blue") + ] + for (index, (channel, name)) in channels.enumerated() { + let expected = (theme[keyPath: channel] * opacity + base[keyPath: channel] * (1 - opacity)) * 255 + XCTAssertEqual( + actual[index], + expected, + accuracy: 1.01, + "\(name) channel of \(hex) is not the theme blended over the window base", + file: file, + line: line + ) + } + } } // WindowTransparencyDecisionTests was deleted: its subjects (the free functions @@ -1706,6 +1783,36 @@ final class BrowserPanelPopupContextTests: XCTestCase { @MainActor final class BrowserPanelWebViewLifecycleTests: XCTestCase { + /// Waits for the initial load to settle on both sides: WebKit's `isLoading` + /// and the panel's own `isLoading`, which stays true for the loading + /// indicator's minimum duration after WebKit finishes. Either flag is a + /// "loading" discard blocker, so polling `webView.isLoading` for one second + /// raced both the indicator floor and the WebContent process launch on the + /// CI hosts. + private func waitForInitialLoadToSettle( + _ panel: BrowserPanel, + timeout: TimeInterval = 10, + file: StaticString = #filePath, + line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(timeout) + while panel.webView.isLoading || panel.isLoading, Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + XCTAssertFalse( + panel.webView.isLoading, + "Timed out waiting for about:blank to finish loading", + file: file, + line: line + ) + XCTAssertFalse( + panel.isLoading, + "Timed out waiting for the panel loading indicator to settle", + file: file, + line: line + ) + } + func testHiddenDiscardPolicyReadsUserDefaults() throws { let suiteName = "cmux.browserHiddenDiscardPolicyTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -1740,9 +1847,18 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { if !hasDelayEnvironmentOverride { XCTAssertEqual(BrowserHiddenWebViewDiscardPolicy.hiddenDelay(defaults: defaults), 42.5) + // Stored values outside [minimum, maximum] are rejected, not clamped: + // `resolvedHiddenDelay` returns nil so the cmux.json loader can report + // an invalid `browser.hiddenWebViewDiscardDelaySeconds`, and the runtime + // falls back to the default delay. Clamping is only the in-range helper. defaults.set(7200, forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey) + XCTAssertNil(BrowserHiddenWebViewDiscardPolicy.resolvedHiddenDelay(7200)) XCTAssertEqual( BrowserHiddenWebViewDiscardPolicy.hiddenDelay(defaults: defaults), + BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay + ) + XCTAssertEqual( + BrowserHiddenWebViewDiscardPolicy.clampedHiddenDelay(7200), BrowserHiddenWebViewDiscardPolicy.maximumHiddenDelay ) @@ -1905,15 +2021,16 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { ) defer { panel.close() } - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.isLoading, - RunLoop.main.run(mode: .default, before: deadline), - Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") + waitForInitialLoadToSettle(panel) panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) let originalWebView = panel.webView + XCTAssertEqual( + panel.webViewLifecycleTopPayload(now: discardedAt)["discard_blockers"] as? [String], + [], + "A hidden, idle about:blank webview must be discard-eligible" + ) XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) XCTAssertFalse(panel.webView === originalWebView) XCTAssertFalse(panel.shouldRenderWebView) @@ -1954,11 +2071,7 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { ) defer { panel.close() } - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.isLoading, - RunLoop.main.run(mode: .default, before: deadline), - Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") + waitForInitialLoadToSettle(panel) panel.noteWebViewVisibility(true, reason: "test.visible.first") XCTAssertEqual(panel.webViewLifecycleState, .liveVisible) @@ -2001,11 +2114,7 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { ) defer { panel.close() } - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.isLoading, - RunLoop.main.run(mode: .default, before: deadline), - Date() < deadline {} - XCTAssertFalse(panel.webView.isLoading, "Timed out waiting for about:blank to finish loading") + waitForInitialLoadToSettle(panel) panel.restoreSessionNavigationHistory( backHistoryURLStrings: ["https://example.test/back"], @@ -2015,6 +2124,11 @@ final class BrowserPanelWebViewLifecycleTests: XCTestCase { XCTAssertTrue(panel.canGoBack) panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + XCTAssertEqual( + panel.webViewLifecycleTopPayload(now: discardedAt)["discard_blockers"] as? [String], + [], + "A hidden, idle about:blank webview must be discard-eligible" + ) XCTAssertTrue(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) XCTAssertEqual(panel.webViewLifecycleState, .discarded) @@ -2242,11 +2356,23 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { ) let baseURL = try XCTUnwrap(URL(string: "http://cmux-loopback.localtest.me:3000/")) + // Every panel-driven load applies the destination identity before it + // starts (`browserLoadRequest`). A direct `loadHTMLString` with an HTTP + // base URL skips that step, so the navigation delegate's user-agent + // restart cancels the data navigation and replays a plain request for + // the base URL, which a remote pane without a proxy endpoint defers; + // the JavaScript below would then run in the initial empty document. + panel.webView.applyBrowserUserAgentPolicy(for: baseURL) panel.webView.loadHTMLString( "remote loopback bridge", baseURL: baseURL ) try await waitForBrowserWebViewLoad(panel.webView) + XCTAssertEqual( + panel.webView.url, + baseURL, + "The HTML document must be the current navigation; a cancelled or replayed load leaves the initial empty document" + ) let result = try await panel.evaluateJavaScript( """ @@ -2295,7 +2421,7 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.webView.url?.host, "localhost") } - private func waitForBrowserWebViewLoad(_ webView: WKWebView, timeout: TimeInterval = 2.0) async throws { + private func waitForBrowserWebViewLoad(_ webView: WKWebView, timeout: TimeInterval = 10.0) async throws { let deadline = Date().addingTimeInterval(timeout) while webView.isLoading { if Date() >= deadline { @@ -4393,8 +4519,11 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { _CMUX_TTY_REPORTED=1 _CMUX_PORTS_LAST_RUN=-999 _cmux_precmd - repeat 20; do - [[ -s "\(logPath.path)" ]] && break + # precmd reports the prompt state and kicks the port scan from two + # independent background children. Wait for the kick itself, not + # for whichever line lands first. + repeat 200; do + /usr/bin/grep -q 'surface.ports_kick' "\(logPath.path)" && break sleep 0.05 done cat "\(logPath.path)" @@ -4538,8 +4667,10 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { _CMUX_TTY_REPORTED=1 _CMUX_PORTS_LAST_RUN=-999 _cmux_prompt_command - for _cmux_i in $(seq 1 20); do - [ -s "\(logPath.path)" ] && break + # The prompt hook may send other relay RPCs from separate background + # children. Wait for the kick itself, not for whichever line lands first. + for _cmux_i in $(seq 1 200); do + /usr/bin/grep -q 'surface.ports_kick' "\(logPath.path)" && break sleep 0.05 done cat "\(logPath.path)" diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 1c33b04b8288..8b7adf720112 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -456,11 +456,25 @@ struct GhosttyTerminalViewVisibilityPolicyTests { panel.hostedView.setVisibleInUI(false) TerminalWindowPortalRegistry.hideHostedView(panel.hostedView) - container.nextLayout = { anchor.frame.size.width = 280 } - _ = portal.updateEntryVisibility( - forHostedId: ObjectIdentifier(panel.hostedView), - visibleInUI: true + // Hiding retires the hosted view from the window (#12607); only a + // bind reinstalls it. Reveal the way workspace reconciliation does + // (TerminalPortalReconciliation rebinds a hosted view with no + // superview) instead of flipping portal visibility on a detached view. + #expect(panel.hostedView.superview == nil, "Hiding must retire the hosted view from the window") + #expect( + portal.hostedViewNeedsPortalReattachForVisiblePresentation( + withId: ObjectIdentifier(panel.hostedView) + ), + "Revealing a retired hosted view must request a portal reattach" + ) + TerminalWindowPortalRegistry.bind( + hostedView: panel.hostedView, + to: anchor, + visibleInUI: true, + expectedSurfaceId: panel.surface.id, + expectedGeneration: panel.surface.portalBindingGeneration() ) + container.nextLayout = { anchor.frame.size.width = 280 } panel.hostedView.setVisibleInUI(true) container.needsLayout = true container.resetLayoutCount() @@ -486,17 +500,6 @@ struct GhosttyTerminalViewVisibilityPolicyTests { anchor.frame.size.width = 360 TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window, forceImmediate: false) await flushPortalReconciliationPasses() - // Native size publication also waits for AppKit's display/layout - // turn. Main-queue barriers alone do not drive that turn in an async test. - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - while (try terminalSize()).width >= initialTerminalSize.width, - clock.now < deadline { - window.displayIfNeeded() - panel.hostedView.layoutSubtreeIfNeeded() - _ = panel.hostedView.reconcileGeometryNow() - await flushPortalReconciliationPasses() - } #expect(panel.hostedView.frame.width == 360) #expect((try terminalSize()).width < initialTerminalSize.width) #expect( diff --git a/cmuxTests/LiveWorkspaceFixture.swift b/cmuxTests/LiveWorkspaceFixture.swift new file mode 100644 index 000000000000..889ad2af292e --- /dev/null +++ b/cmuxTests/LiveWorkspaceFixture.swift @@ -0,0 +1,93 @@ +import Foundation +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Real local workspaces that a test's `SurfaceCatalog` resolves as live destinations. +/// +/// `SurfaceCatalog.validateOwnership` refuses to project, materialize or restore into a +/// workspace it cannot resolve, so a made-up workspace ID fails with `destinationNotFound`. +/// Register every local workspace a test names, then build its catalog with +/// `renameService`, the same lookup the app installs at its composition root. +/// +/// Only `workspace(_:)` resolves. `workspaces()` stays empty so the catalog's native +/// projection coordinator does not start mirroring Cloud tabs into these workspaces. +@MainActor +final class LiveWorkspaceFixture { + private var workspacesByID: [UUID: Workspace] = [:] + + init(_ ids: UUID...) { + for id in ids { add(id: id) } + } + + /// Creates and registers a live workspace, keeping `id` when the test already chose one. + @discardableResult + func add(id: UUID = UUID()) -> Workspace { + if let existing = workspacesByID[id] { return existing } + let workspace = Workspace(id: id) + workspacesByID[id] = workspace + return workspace + } + + /// Registers a workspace the test built itself. + func register(_ workspace: Workspace) { + workspacesByID[workspace.id] = workspace + } + + /// The ID of a newly registered live workspace. + func id() -> UUID { + add().id + } + + subscript(id: UUID) -> Workspace? { + workspacesByID[id] + } + + var environment: CloudWorkspaceRenameEnvironment { + CloudWorkspaceRenameEnvironment(workspace: { [self] id in workspacesByID[id] }) + } + + var renameService: CloudWorkspaceRenameService { + CloudWorkspaceRenameService(environment: environment) + } + + /// Closes the panels of the workspaces this fixture created or registered. + func tearDown() { + for workspace in workspacesByID.values { workspace.teardownAllPanels() } + workspacesByID.removeAll() + } +} + +extension LiveWorkspaceFixture { + /// `SurfaceCatalog.shared` resolves workspaces through the app delegate. Registers + /// `manager` as a windowless main-window context for the duration of `body`, so its + /// workspaces are live destinations there too. Call it from synchronous tests only: + /// the registration is process-global while `body` runs. + static func withAppRegistration(of manager: TabManager, _ body: () throws -> T) rethrows -> T { + guard let app = AppDelegate.shared else { return try body() } + let windowId = app.registerMainWindowContextForTesting(tabManager: manager) + defer { + app.unregisterMainWindowContextForTesting(windowId: windowId) + app.forgetRecoverableMainWindowRoute(windowId: windowId) + } + return try body() + } +} + +extension SurfaceCatalog { + /// A catalog whose local destinations resolve through `live`. + @MainActor + convenience init( + live: LiveWorkspaceFixture, + cloudPlacementCoordinator: CloudPlacementCoordinator? = nil, + cloudWorkspaceProjectionCoordinator: CloudWorkspaceProjectionCoordinator? = nil + ) { + self.init( + cloudWorkspaceRenameService: live.renameService, + cloudPlacementCoordinator: cloudPlacementCoordinator, + cloudWorkspaceProjectionCoordinator: cloudWorkspaceProjectionCoordinator + ) + } +} diff --git a/cmuxTests/MachineCreateCoordinatorTests.swift b/cmuxTests/MachineCreateCoordinatorTests.swift index 93a3904e1320..864e1971477b 100644 --- a/cmuxTests/MachineCreateCoordinatorTests.swift +++ b/cmuxTests/MachineCreateCoordinatorTests.swift @@ -614,7 +614,9 @@ struct MachinesPanelPendingCreateTests { /// Regression: while `cmux vm new --name troll` was still opening its /// terminal, the fleet list (and, before it, the catalog) already showed /// "troll", so the panel listed "troll · Creating…" above "troll". The - /// stand-in row exists only until the machine has a row of its own. + /// stand-in row exists only until the machine has a row of its own. Since + /// #12919 that row keeps the running create's node identity (selection and + /// expansion survive adoption), so the assertions check the row's kind too. @Test func pendingRowStepsAsideOnceItsMachineHasARow() { let started = Date(timeIntervalSince1970: 1_787_400_000) let named = MachineCreateOperation( @@ -635,32 +637,48 @@ struct MachinesPanelPendingCreateTests { isDesktop: false, activity: .ready, createdAt: createdAt, label: label ) } - func rows(machines: [MachineSnapshot], catalog: [SurfaceMachineInfo] = [], pending: [MachineCreateOperation]) -> [String] { + func nodes(machines: [MachineSnapshot], catalog: [SurfaceMachineInfo] = [], pending: [MachineCreateOperation]) -> [CloudTreeNode] { CloudTreeNodeBuilder.nodes( machines: machines, pendingCreates: pending, snapshot: SurfaceCatalogSnapshot(machines: catalog, resources: [], projections: []), localWorkspaces: [] - ).map(\.id) + ) + } + func rows(machines: [MachineSnapshot], catalog: [SurfaceMachineInfo] = [], pending: [MachineCreateOperation]) -> [String] { + nodes(machines: machines, catalog: catalog, pending: pending).map(\.id) + } + /// The machine a row shows, nil for a stand-in (pending) row. + func machineID(of rowID: String, machines: [MachineSnapshot], catalog: [SurfaceMachineInfo] = [], pending: [MachineCreateOperation]) -> String? { + guard case .machine(let machine, _)? = nodes(machines: machines, catalog: catalog, pending: pending) + .first(where: { $0.id == rowID })?.kind else { return nil } + return machine.id } + let namedRow = "pending-machine:\(named.id.uuidString)" + let unnamedRow = "pending-machine:\(unnamed.id.uuidString)" let older = machine("old-hare", label: "troll", createdAt: started.addingTimeInterval(-3_600)) let created = machine("vm-e0382b", label: "troll", createdAt: started.addingTimeInterval(20)) let anonymous = machine("calm-petrel", label: nil, createdAt: started.addingTimeInterval(20)) // The fleet list returned the named machine: its stand-in is gone; an // older machine that happens to share the label is not it. - #expect(rows(machines: [older], pending: [named]) == ["pending-machine:\(named.id.uuidString)", "machine:old-hare"]) - #expect(rows(machines: [older, created], pending: [named]) == ["machine:old-hare", "machine:vm-e0382b"]) + #expect(rows(machines: [older], pending: [named]) == [namedRow, "machine:old-hare"]) + #expect(machineID(of: namedRow, machines: [older], pending: [named]) == nil) + #expect(rows(machines: [older, created], pending: [named]) == ["machine:old-hare", namedRow]) + #expect(machineID(of: namedRow, machines: [older, created], pending: [named]) == "vm-e0382b") // The catalog registered it (the CLI is opening it) before the fleet // list caught up: the catalog row is the machine's row. let catalogTroll = SurfaceMachineInfo( id: .cloud("vm-e0382b"), name: "troll", status: "running", image: nil, hasDesktop: false, memoryMb: nil, diskMb: nil, linkState: .connecting, linkError: nil, cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil ) - #expect(rows(machines: [], catalog: [catalogTroll], pending: [named]) == ["machine:vm-e0382b"]) + #expect(rows(machines: [], catalog: [catalogTroll], pending: [named]) == [namedRow]) + #expect(machineID(of: namedRow, machines: [], catalog: [catalogTroll], pending: [named]) == "vm-e0382b") // An unnamed create is the machine that appeared after it started. - #expect(rows(machines: [older], pending: [unnamed]) == ["pending-machine:\(unnamed.id.uuidString)", "machine:old-hare"]) - #expect(rows(machines: [anonymous], pending: [unnamed]) == ["machine:calm-petrel"]) + #expect(rows(machines: [older], pending: [unnamed]) == [unnamedRow, "machine:old-hare"]) + #expect(machineID(of: unnamedRow, machines: [older], pending: [unnamed]) == nil) + #expect(rows(machines: [anonymous], pending: [unnamed]) == [unnamedRow]) + #expect(machineID(of: unnamedRow, machines: [anonymous], pending: [unnamed]) == "calm-petrel") // A failed create keeps its row so it can be retried or dismissed. var failed = named failed.phase = .failed(output: "Error: quota") diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 22f04057c77c..ad1b0e1f5f30 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -780,12 +780,8 @@ final class MachinesPanelModelTests: XCTestCase { catalog.register(provider) XCTAssertTrue(catalog.replaceResources([resource], on: machine, info: provider.info, from: provider)) - XCTAssertThrowsError(try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: workspace.id)) { error in - XCTAssertEqual( - error as? SurfaceCatalogError, - .destinationNotFound("workspace ws_legacy on legacy-group-test has no projectable resources") - ) - } + let group = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: workspace.id) + XCTAssertEqual(group.placements.map(\.resource), [resource.id]) } func testCloudTreeLocalBrowsersGroupAndEmptyLocalPlaceholder() { @@ -892,13 +888,15 @@ final class MachinesPanelModelTests: XCTestCase { @MainActor func testProjectGroupLandsTheFirstAtTheDropAndTheRestAsTabsOfThatPane() async throws { - let catalog = SurfaceCatalog() + let live = LiveWorkspaceFixture() + defer { live.tearDown() } + let catalog = SurfaceCatalog(live: live) let provider = GroupFakeProvider(machine: .cloud("m")) catalog.register(provider) let a = terminal(.cloud("m"), "term_a"), b = terminal(.cloud("m"), "term_b") let browser = SurfaceResource(id: SurfaceResourceID(machine: .cloud("m"), kind: .browser, key: "port:3000"), title: ":3000", detail: nil, lifecycle: .running, agent: nil, remoteWorkspace: nil, port: 3000, url: nil) catalog.replaceResources([a, b, browser], on: .cloud("m")) - let ws = UUID() + let ws = live.id() let missing = SurfaceResourceID(machine: .cloud("m"), kind: .terminal, key: "term_gone") let drop = SurfaceDestination.split(workspaceID: ws, paneID: "pane-drop", direction: .left) diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift index 441fcf82d21f..efe91f8e6a6d 100644 --- a/cmuxTests/MarkdownPanelTests.swift +++ b/cmuxTests/MarkdownPanelTests.swift @@ -856,10 +856,7 @@ final class MarkdownPanelTests: XCTestCase { MarkdownViewerAssets.shared.shellHTML(isDark: true), baseURL: FileManager.default.temporaryDirectory.appendingPathComponent("scroll.md") ) - await fulfillment(of: [loaded], timeout: 5) - if let error = loadDelegate.error { - throw error - } + try await awaitMarkdownShellLoad(loaded, delegate: loadDelegate) try await renderMarkdown(scrollSmokeMarkdown(extraBeforeSection20: false), in: webView) let before = try await evaluateScrollSnapshot( @@ -944,10 +941,7 @@ final class MarkdownPanelTests: XCTestCase { MarkdownViewerAssets.shared.shellHTML(isDark: true), baseURL: markdownURL ) - await fulfillment(of: [loaded], timeout: 5) - if let error = loadDelegate.error { - throw error - } + try await awaitMarkdownShellLoad(loaded, delegate: loadDelegate) defer { coordinator.cancelLocalImageLoads() } try await renderMarkdown( @@ -1041,10 +1035,7 @@ final class MarkdownPanelTests: XCTestCase { MarkdownViewerAssets.shared.shellHTML(isDark: true), baseURL: markdownURL ) - await fulfillment(of: [loaded], timeout: 5) - if let error = loadDelegate.error { - throw error - } + try await awaitMarkdownShellLoad(loaded, delegate: loadDelegate) try await renderMarkdown("![Inline pixel](\(Self.onePixelPNGDataURI))\n", in: webView) let image = try await waitForMarkdownImage(in: webView) @@ -1087,10 +1078,7 @@ final class MarkdownPanelTests: XCTestCase { MarkdownViewerAssets.shared.shellHTML(isDark: true), baseURL: markdownURL ) - await fulfillment(of: [loaded], timeout: 5) - if let error = loadDelegate.error { - throw error - } + try await awaitMarkdownShellLoad(loaded, delegate: loadDelegate) let expectedBlockedTitle = String( localized: "markdown.web.remoteImageBlocked", @@ -1206,7 +1194,7 @@ final class MarkdownPanelTests: XCTestCase { XCTAssertTrue(beforePlaceholders.contains { $0.contains(expectedHTTPSOnlyMessage) }) XCTAssertTrue(beforePlaceholders.contains { $0.contains(expectedNotAllowedMessage) }) XCTAssertTrue(beforePlaceholders.contains { $0.contains("http://images.example.com/pixel.png") }) - let copiedHTTPImageURL = try await webView.evaluateJavaScript( + let copiedHTTPImageURL = try await evaluateJavaScript( """ (function() { window.__copiedRemoteImageURLs = []; @@ -1226,26 +1214,19 @@ final class MarkdownPanelTests: XCTestCase { if (button) { button.click(); } return window.__copiedRemoteImageURLs; })(); - """ + """, + in: webView, + describedAs: "copy the blocked HTTP remote image URL" ) let copiedHTTPImageURLs = try XCTUnwrap(copiedHTTPImageURL as? [String]) XCTAssertEqual(copiedHTTPImageURLs, ["http://images.example.com/pixel.png"]) - try await Task.sleep(nanoseconds: 100_000_000) - let copiedHTTPButtonState = try await webView.evaluateJavaScript( - """ - (function() { - var img = document.querySelector('img[alt="HTTP remote"]'); - var id = img && img.getAttribute('data-cmux-remote-placeholder-id'); - var placeholder = id && document.querySelector('[data-cmux-remote-placeholder-for="' + id + '"]'); - var button = placeholder && placeholder.querySelectorAll('button')[0]; - return { - text: button ? button.textContent : '', - copied: button ? button.getAttribute('data-copied') : '' - }; - })(); - """ + // The button flips to "Copied" from the promise `writeText` returns, so + // wait on that DOM transition rather than on a fixed delay. + let copiedHTTPButton = try await waitForRemoteImageButtonCopied( + alt: "HTTP remote", + expectedText: expectedCopiedButton, + in: webView ) - let copiedHTTPButton = try XCTUnwrap(copiedHTTPButtonState as? [String: Any]) XCTAssertEqual(copiedHTTPButton["text"] as? String, expectedCopiedButton) XCTAssertEqual(copiedHTTPButton["copied"] as? String, "1") let restoredHTTPButton = try await waitForRemoteImageButtonRevert( @@ -1255,7 +1236,7 @@ final class MarkdownPanelTests: XCTestCase { ) XCTAssertEqual(restoredHTTPButton["text"] as? String, expectedCopyURLButton) XCTAssertNil(restoredHTTPButton["copied"] as? String) - let openedHTTPImageURL = try await webView.evaluateJavaScript( + let openedHTTPImageURL = try await evaluateJavaScript( """ (function() { var opened = []; @@ -1270,12 +1251,14 @@ final class MarkdownPanelTests: XCTestCase { if (button) { button.click(); } return opened; })(); - """ + """, + in: webView, + describedAs: "open the blocked HTTP remote image URL" ) let openedHTTPImageURLs = try XCTUnwrap(openedHTTPImageURL as? [[String: Any]]) XCTAssertEqual(openedHTTPImageURLs.first?["url"] as? String, "http://images.example.com/pixel.png") XCTAssertEqual(openedHTTPImageURLs.first?["target"] as? String, "_blank") - let linkedPlaceholderClickResult = try await webView.evaluateJavaScript( + let linkedPlaceholderClickResult = try await evaluateJavaScript( """ (function() { var img = document.querySelector('img[alt="Linked remote"]'); @@ -1288,11 +1271,13 @@ final class MarkdownPanelTests: XCTestCase { cancelable: true })); })(); - """ + """, + in: webView, + describedAs: "click the linked remote image placeholder" ) let linkedPlaceholderClickAllowed = try XCTUnwrap(linkedPlaceholderClickResult as? Bool) XCTAssertFalse(linkedPlaceholderClickAllowed) - let linkedPlaceholderInsideAnchor = try await webView.evaluateJavaScript( + let linkedPlaceholderInsideAnchor = try await evaluateJavaScript( """ (function() { var img = document.querySelector('img[alt="Linked remote"]'); @@ -1300,11 +1285,13 @@ final class MarkdownPanelTests: XCTestCase { var placeholder = id && document.querySelector('[data-cmux-remote-placeholder-for="' + id + '"]'); return !!(placeholder && placeholder.closest('a')); })(); - """ + """, + in: webView, + describedAs: "check whether the linked remote image placeholder sits inside an anchor" ) XCTAssertEqual(linkedPlaceholderInsideAnchor as? Bool, false) - _ = try await webView.evaluateJavaScript( + _ = try await evaluateJavaScript( """ (function() { var img = document.querySelector('img[alt="Linked remote"]'); @@ -1313,7 +1300,9 @@ final class MarkdownPanelTests: XCTestCase { var button = placeholder && placeholder.querySelector('button'); if (button) { button.click(); } })(); - """ + """, + in: webView, + describedAs: "load the linked remote image" ) let loading = try await remoteImageSnapshot(in: webView) let loadingImages = try XCTUnwrap(loading["images"] as? [[String: Any]]) @@ -1342,14 +1331,16 @@ final class MarkdownPanelTests: XCTestCase { XCTAssertTrue(activeLoadingButtons.allSatisfy { $0["text"] as? String == expectedLoadingButton }) XCTAssertTrue(activeLoadingButtons.allSatisfy { $0["disabled"] as? Bool == true }) - _ = try await webView.evaluateJavaScript( + _ = try await evaluateJavaScript( """ (function() { Array.prototype.slice.call(document.querySelectorAll('img[src^="cmux-remote-image://"]')).forEach(function(img) { img.dispatchEvent(new Event('load')); }); })(); - """ + """, + in: webView, + describedAs: "deliver load events to the approved remote images" ) let after = try await remoteImageSnapshot(in: webView) let afterImages = try XCTUnwrap(after["images"] as? [[String: Any]]) @@ -1419,13 +1410,15 @@ final class MarkdownPanelTests: XCTestCase { XCTAssertEqual(autoLoadingButtons.filter { $0 == expectedOpenURLButton }.count, 1) XCTAssertEqual(autoLoadingButtonStates.filter { $0["loading"] as? String == "1" }.count, 1) - _ = try await webView.evaluateJavaScript( + _ = try await evaluateJavaScript( """ (function() { var img = document.querySelector('img[alt="Auto approved remote"]'); if (img) { img.dispatchEvent(new Event('error')); } })(); - """ + """, + in: webView, + describedAs: "deliver an error event to the auto-approved remote image" ) let autoFailed = try await remoteImageSnapshot(in: webView) let autoFailedImages = try XCTUnwrap(autoFailed["images"] as? [[String: Any]]) @@ -1608,23 +1601,135 @@ final class MarkdownPanelTests: XCTestCase { ) } + /// Waits for the markdown shell's first navigation to reach a terminal + /// outcome, reported by `WKNavigationDelegate` — the real signal, not a + /// poll of `isLoading`. + /// + /// If no terminal callback arrives the shell is not usable, and every + /// WebKit call the test makes next would wait on a reply that cannot come. + /// Fail here, with the reason, rather than letting the test walk into a + /// hang that costs the whole app host. + private func awaitMarkdownShellLoad( + _ loaded: XCTestExpectation, + delegate: MarkdownShellLoadDelegate, + timeout: TimeInterval = 10, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + await fulfillment(of: [loaded], timeout: timeout) + guard delegate.didSettle else { + let failure = MarkdownShellNeverLoaded(timeout: timeout) + XCTFail(failure.failureMessage, file: file, line: line) + throw failure + } + if let error = delegate.error { + throw error + } + } + + /// Runs a WebKit call with a deadline that only bounds the failure path. + /// + /// `evaluateJavaScript` and `callAsyncJavaScript` resume their continuation + /// from a reply message posted by the web content process. If that reply + /// never comes — the web content process is gone, or the script awaits + /// something this host never produces — the continuation is never resumed + /// and there is nothing to cancel: the task is parked forever. XCTest then + /// kills the whole app host at its five minute execution allowance, which + /// restarts the shard and abandons every test that had not run yet, so one + /// stuck await destroys the run (cmux#13178, cmux#13421). + /// + /// The awaited value is still the real completion signal and returns the + /// instant the reply arrives; the deadline only decides how a missing reply + /// is reported. A loud, immediate failure costs one test, a hung host costs + /// the shard. + private func awaitingWebKitReply( + _ description: String, + timeout: TimeInterval = 20, + file: StaticString = #filePath, + line: UInt = #line, + operation: @escaping @MainActor () async throws -> Any? + ) async throws -> Any? { + let reply = MarkdownWebKitReply() + + Task { @MainActor in + do { + let value = try await operation() + reply.settle(.success(value)) + } catch { + reply.settle(.failure(error)) + } + } + let watchdog = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + reply.settle(.failure(MarkdownWebKitReplyMissing(what: description, timeout: timeout))) + } + + await reply.wait() + watchdog.cancel() + + // The operation task is deliberately not awaited here. A WebKit reply + // that never arrives cannot be cancelled, so waiting for that task to + // unwind would reproduce exactly the hang this guard exists to prevent. + guard let result = reply.result else { + let failure = MarkdownWebKitReplyMissing(what: description, timeout: timeout) + XCTFail(failure.failureMessage, file: file, line: line) + throw failure + } + switch result { + case .success(let value): + return value + case .failure(let error): + if let missing = error as? MarkdownWebKitReplyMissing { + XCTFail(missing.failureMessage, file: file, line: line) + } + throw error + } + } + + private func evaluateJavaScript( + _ script: String, + in webView: WKWebView, + describedAs description: String, + timeout: TimeInterval = 20, + file: StaticString = #filePath, + line: UInt = #line + ) async throws -> Any? { + try await awaitingWebKitReply(description, timeout: timeout, file: file, line: line) { + try await webView.evaluateJavaScript(script) + } + } + private func renderMarkdown(_ markdown: String, in webView: WKWebView) async throws { - _ = try await webView.callAsyncJavaScript( - """ - window.__cmuxRenderMarkdown(markdown); - // The renderer restores its scroll anchor over two animation frames. - // Finish that transaction before a test scrolls or renders again. - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - return true; - """, - arguments: ["markdown": markdown], - in: nil, - contentWorld: .page - ) + // `window.__cmuxRenderMarkdown` is the viewer's own render contract: it + // parses the markdown, replaces `#content`, rewrites local and remote + // image sources, post-processes special blocks and applies the + // scroll-anchor restore, all before it returns. That synchronous return + // is what `MarkdownWebRenderer.Coordinator.pushMarkdown` waits for + // before it reports `onMarkdownRendered`, so it is the product's real + // readiness signal and the right one for a test to wait on. + // + // Do not wait on `requestAnimationFrame` here. App-host tests run behind + // XCTest's shielding window, where WebKit suspends the rendering update + // and animation-frame callbacks never fire at all, so an awaited frame + // is a gate this host can never open. The viewer's trailing restore + // passes are refinements that now abandon themselves once the viewer has + // moved (see `restoreMarkdownScrollState` in shell.html), so a later + // scroll by this test can no longer be clobbered by a queued frame. + _ = try await awaitingWebKitReply("render markdown into the viewer shell") { + try await webView.callAsyncJavaScript( + """ + window.__cmuxRenderMarkdown(markdown); + return true; + """, + arguments: ["markdown": markdown], + in: nil, + contentWorld: .page + ) + } } private func evaluateScrollSnapshot(_ script: String, in webView: WKWebView) async throws -> [String: Double] { - let result = try await webView.evaluateJavaScript(script) + let result = try await evaluateJavaScript(script, in: webView, describedAs: "read the viewer scroll snapshot") let raw = try XCTUnwrap(result as? [String: Any]) var snapshot: [String: Double] = [:] for (key, value) in raw { @@ -1645,7 +1750,7 @@ final class MarkdownPanelTests: XCTestCase { var lastSnapshot: [[String: Any]] = [] while Date() < deadline { - let result = try await webView.evaluateJavaScript( + let result = try await evaluateJavaScript( """ (function() { return Array.prototype.slice.call(document.querySelectorAll('img')).map(function(img) { @@ -1662,7 +1767,9 @@ final class MarkdownPanelTests: XCTestCase { }; }); })(); - """ + """, + in: webView, + describedAs: "read the rendered markdown images" ) lastSnapshot = try XCTUnwrap(result as? [[String: Any]]) if lastSnapshot.count == expectedCount, @@ -1681,19 +1788,26 @@ final class MarkdownPanelTests: XCTestCase { ) } - private func waitForRemoteImageButtonRevert( + /// Waits for a remote-image placeholder button to reach a state. + /// + /// Both states this drives are produced asynchronously inside the viewer + /// shell, in a separate WebKit process: "Copied" is set from the promise + /// `navigator.clipboard.writeText` returns, and the revert back to "Copy + /// image URL" comes from a JS `setTimeout`. Poll the real DOM transition + /// rather than racing a fixed sleep against either. The deadline bounds only + /// the failure path, so the wait returns the instant the state holds. + private func waitForRemoteImageButtonState( alt: String, - expectedText: String, - in webView: WKWebView + description: String, + timeout: TimeInterval, + in webView: WKWebView, + until isSatisfied: ([String: Any]) -> Bool ) async throws -> [String: Any] { - // The "Copied" label reverts to "Copy image URL" via a JS setTimeout in the - // markdown viewer shell, which runs in a separate WebKit process. Poll the real - // DOM transition instead of racing a fixed sleep against that timer. - let deadline = Date().addingTimeInterval(8) + let deadline = Date().addingTimeInterval(timeout) var lastSnapshot: [String: Any] = [:] while Date() < deadline { - let result = try await webView.evaluateJavaScript( + let result = try await evaluateJavaScript( """ (function() { var img = document.querySelector('img[alt="\(alt)"]'); @@ -1705,11 +1819,12 @@ final class MarkdownPanelTests: XCTestCase { copied: button ? button.getAttribute('data-copied') : '' }; })(); - """ + """, + in: webView, + describedAs: "read the remote image button label" ) lastSnapshot = try XCTUnwrap(result as? [String: Any]) - if lastSnapshot["text"] as? String == expectedText, - lastSnapshot["copied"] == nil || lastSnapshot["copied"] is NSNull { + if isSatisfied(lastSnapshot) { return lastSnapshot } try await Task.sleep(nanoseconds: 50_000_000) @@ -1719,13 +1834,44 @@ final class MarkdownPanelTests: XCTestCase { domain: "MarkdownPanelTests", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for remote image button to revert to \(expectedText). Last snapshot: \(lastSnapshot)" + NSLocalizedDescriptionKey: "Timed out waiting for the \(alt) remote image button to \(description). Last snapshot: \(lastSnapshot)" ] ) } + private func waitForRemoteImageButtonCopied( + alt: String, + expectedText: String, + in webView: WKWebView + ) async throws -> [String: Any] { + try await waitForRemoteImageButtonState( + alt: alt, + description: "show \(expectedText)", + timeout: 8, + in: webView + ) { snapshot in + snapshot["text"] as? String == expectedText && snapshot["copied"] as? String == "1" + } + } + + private func waitForRemoteImageButtonRevert( + alt: String, + expectedText: String, + in webView: WKWebView + ) async throws -> [String: Any] { + try await waitForRemoteImageButtonState( + alt: alt, + description: "revert to \(expectedText)", + timeout: 8, + in: webView + ) { snapshot in + snapshot["text"] as? String == expectedText + && (snapshot["copied"] == nil || snapshot["copied"] is NSNull) + } + } + private func remoteImageSnapshot(in webView: WKWebView) async throws -> [String: Any] { - let result = try await webView.evaluateJavaScript( + let result = try await evaluateJavaScript( """ (function() { return { @@ -1762,7 +1908,9 @@ final class MarkdownPanelTests: XCTestCase { renderedText: window.__cmuxRenderedText ? window.__cmuxRenderedText() : '' }; })(); - """ + """, + in: webView, + describedAs: "read the remote image snapshot" ) return try XCTUnwrap(result as? [String: Any]) } @@ -1827,10 +1975,74 @@ final class MarkdownPanelTests: XCTestCase { private static let onePixelPNGDataURI = "data:image/png;base64,\(onePixelPNG.base64EncodedString())" } +/// The markdown shell's first navigation never reached a terminal outcome. +private struct MarkdownShellNeverLoaded: LocalizedError { + let timeout: TimeInterval + + var failureMessage: String { + """ + The markdown viewer shell never became ready: WKNavigationDelegate \ + reported neither didFinish nor a navigation failure within \ + \(Int(timeout))s, so the web content process never loaded the shell. \ + Every WebKit call after this point would wait forever on a reply that \ + cannot arrive, so the test stops here instead of hanging the app host. + """ + } + + var errorDescription: String? { failureMessage } +} + +/// A WebKit reply that never arrived within the test's deadline. +private struct MarkdownWebKitReplyMissing: LocalizedError { + let what: String + let timeout: TimeInterval + + var failureMessage: String { + """ + WebKit never replied to: \(what) (waited \(Int(timeout))s). + The markdown shell never became ready: either the web content process is \ + gone, or the script is waiting for something this test host never \ + produces (app-host tests run behind XCTest's shielding window, where \ + WebKit suspends the rendering update, so requestAnimationFrame and \ + anything chained off it never fire). evaluateJavaScript and \ + callAsyncJavaScript resume only from a reply message, so this await \ + would otherwise never return and XCTest would kill the whole app host at \ + its 5 minute allowance, taking the rest of the shard with it. + """ + } + + var errorDescription: String? { failureMessage } +} + +/// One-shot gate that resumes on the first of a WebKit reply or a deadline. +@MainActor +private final class MarkdownWebKitReply { + private(set) var result: Result? + private var continuation: CheckedContinuation? + + func settle(_ result: Result) { + guard self.result == nil else { return } + self.result = result + continuation?.resume() + continuation = nil + } + + func wait() async { + guard result == nil else { return } + await withCheckedContinuation { continuation in + if result == nil { + self.continuation = continuation + } else { + continuation.resume() + } + } + } +} + private final class MarkdownShellLoadDelegate: NSObject, WKNavigationDelegate { let expectation: XCTestExpectation var error: Error? - private var didSettle = false + private(set) var didSettle = false init(expectation: XCTestExpectation) { self.expectation = expectation diff --git a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift index 11ad965b7957..9e42edeaf5ec 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -71,25 +71,65 @@ struct MobileHostWorkspaceTicketAuthorizationTests { return try CmxAttachTicketCompactCoder().decode(data) } - @Test func pairingTicketUsesThePublishedV2InstallationIdentity() async throws { - let previous = MobileHostPublicStatusCache.currentV2DeviceID() + // `MobileHostPublicStatusCache` is process-wide, and the app host installs a + // `UserDefaults.didChangeNotification` observer (`AppDelegate`.`installMobileHostSettingsObserver`) + // that re-syncs the mobile host on a main-actor task after *any* defaults + // write anywhere in the process. With pairing off, that sync runs + // `MobileHostIrxRuntime.prepareForStop()`, which calls + // `MobileHostPublicStatusCache.removeAll()`. So a fixture publication parked + // in that cache does not survive an `await`: every suspension in this test + // is a window for another suite's defaults write to empty it. This test + // therefore reads the cache and resolves the ticket subject within a single + // main-actor turn instead of driving the async mint entry point. + @Test func pairingTicketUsesThePublishedV2InstallationIdentity() throws { + let deviceID = "123e4567-e89b-42d3-a456-426614174088" + let route = try irohRoute() + let previousDeviceID = MobileHostPublicStatusCache.currentV2DeviceID() let previousRoutes = MobileHostPublicStatusCache.snapshot() defer { - MobileHostPublicStatusCache.updateV2DeviceID(previous) - MobileHostPublicStatusCache.update(routes: previousRoutes) + MobileHostPublicStatusCache.updateV2DeviceID(previousDeviceID) + MobileHostPublicStatusCache.update(routes: previousRoutes.filter { $0.kind != .iroh }) } - let deviceID = "123e4567-e89b-42d3-a456-426614174088" - MobileHostPublicStatusCache.update(routes: [try irohRoute()]) + + // An Iroh ticket must not be minted against the legacy per-install + // identity before the v2 installation identity has been published. + MobileHostPublicStatusCache.update(routes: [route]) MobileHostPublicStatusCache.updateV2DeviceID(nil) - await #expect(throws: MobileAttachTicketStoreError.routeUnavailable) { - try await MobileHostService.shared.createAttachTicket( - workspaceID: "", terminalID: nil, ttl: 60, target: .physicalDevice + #expect(throws: MobileAttachTicketStoreError.routeUnavailable) { + try MobileHostService.attachTicketSubject( + publishedStatus: MobileHostPublicStatusCache.publishedStatus(), + routeID: nil, + routeKind: nil, + target: .physicalDevice ) } + + // Once it is published, the mint takes BOTH halves — the dialable + // routes and the Mac identity — from that same publication. MobileHostPublicStatusCache.updateV2DeviceID(deviceID) - let payload = try await MobileHostService.shared.createAttachTicket( - workspaceID: "", terminalID: nil, ttl: 60, target: .physicalDevice + let published = MobileHostPublicStatusCache.publishedStatus() + #expect(published.routes.contains(route)) + #expect(published.v2DeviceID == deviceID) + let subject = try MobileHostService.attachTicketSubject( + publishedStatus: published, + routeID: nil, + routeKind: nil, + target: .physicalDevice ) + #expect(subject.deviceID == deviceID) + #expect(subject.routes.allSatisfy { $0.kind == .iroh }) + + // ...and that identity reaches the phone through the v2 pairing URL. + let store = MobileAttachTicketStore() + let ticket = try store.createTicket( + workspaceID: "", + terminalID: nil, + routes: subject.routes, + ttl: 60, + macDeviceID: subject.deviceID + ) + #expect(ticket.macDeviceID == deviceID) + let payload = try store.payload(for: ticket, target: .physicalDevice) let url = try #require(payload["attach_url"] as? String) let decoded = try CmxPairingQRCode().decode(try #require(URLComponents(string: url))) #expect(decoded.macDeviceID == deviceID) diff --git a/cmuxTests/NewCloudWorkspaceShortcutTests.swift b/cmuxTests/NewCloudWorkspaceShortcutTests.swift index d2db6ef17819..b1331828b9b0 100644 --- a/cmuxTests/NewCloudWorkspaceShortcutTests.swift +++ b/cmuxTests/NewCloudWorkspaceShortcutTests.swift @@ -196,7 +196,10 @@ final class NewCloudWorkspaceShortcutTests { } } - private func withDefaultPlusMenu(_ body: (NSMenu) throws -> T) throws -> T { + /// The plus menu gates its Cloud rows on the signed-in account, like the + /// command palette and the File menu. A test `AppDelegate()` has no account + /// flow, so the account state is injected through the production seam. + private func withDefaultPlusMenu(isAuthenticated: Bool = true, _ body: (NSMenu) throws -> T) throws -> T { let (store, root) = try loadStore(globalJSON: "{}") defer { try? FileManager.default.removeItem(at: root) } #expect(!store.newWorkspaceContextMenuIsConfigured) @@ -208,7 +211,11 @@ final class NewCloudWorkspaceShortcutTests { ) defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) } let context = try #require(appDelegate.mainWindowContexts.values.first { $0.windowId == windowId }) - let menu = try #require(appDelegate.makeNewWorkspaceContextMenu(context: context, cmuxConfigStore: store)) + let menu = try #require(appDelegate.makeNewWorkspaceContextMenu( + context: context, + cmuxConfigStore: store, + isAuthenticated: isAuthenticated + )) return try body(menu) } @@ -266,6 +273,17 @@ final class NewCloudWorkspaceShortcutTests { } } + @Test func testPlusMenuHidesCloudRowsWhenSignedOut() throws { + defer { restoreState() } + setCloudMachinesEnabled(true) + try withDefaultPlusMenu(isAuthenticated: false) { menu in + let actions = builtInMenuRows(menu).map(\.action) + #expect(!actions.contains(.newCloudWorkspace)) + #expect(!actions.contains(.newCloudMachine)) + #expect(actions.prefix(3).map { $0 } == [.newWorkspace, .newTerminal, .newBrowser]) + } + } + @Test func testPlusMenuHidesBrowserRowWhenBrowserIsDisabled() throws { defer { restoreState() } setCloudMachinesEnabled(true) @@ -292,7 +310,11 @@ final class NewCloudWorkspaceShortcutTests { let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: tabManager, cmuxConfigStore: store) defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) } let context = try #require(appDelegate.mainWindowContexts.values.first { $0.windowId == windowId }) - let menu = try #require(appDelegate.makeNewWorkspaceContextMenu(context: context, cmuxConfigStore: store)) + let menu = try #require(appDelegate.makeNewWorkspaceContextMenu( + context: context, + cmuxConfigStore: store, + isAuthenticated: true + )) let rows = builtInMenuRows(menu) #expect(rows.prefix(2).map(\.action) == [.newTerminal, .newCloudWorkspace]) let cloudRow = try #require(rows.dropFirst().first) @@ -347,6 +369,15 @@ final class NewCloudWorkspaceShortcutTests { setCloudMachinesEnabled(true) let presenter = RecordingSheetPresenter() installDependencies(on: appDelegate, presenter: presenter) + // Shortcut routing bypasses an event bound to a window this delegate + // cannot resolve (the app host's key window), so route the keystroke + // through a registered main window like the rebind test does. + let manager = TabManager() + let windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowID.uuidString)") + appDelegate.mainWindowContexts.values.first { $0.windowId == windowID }?.window = window + defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowID); withExtendedLifetime(window) {} } KeyboardShortcutSettings.resetShortcut(for: .newCloudWorkspace) appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) @@ -355,7 +386,7 @@ final class NewCloudWorkspaceShortcutTests { location: .zero, modifierFlags: [.command], timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: NSApp.keyWindow?.windowNumber ?? 0, + windowNumber: window.windowNumber, context: nil, characters: "y", charactersIgnoringModifiers: "y", @@ -493,6 +524,8 @@ final class NewCloudWorkspaceShortcutTests { let workspace = try #require(manager.selectedWorkspace) workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "selected-machine", isBase: false) let originalIDs = manager.tabs.map(\.id) + let windowID = app.registerMainWindowContextForTesting(tabManager: manager) + defer { app.unregisterMainWindowContextForTesting(windowId: windowID) } #expect(!app.performNewWorkspaceAction(tabManager: manager)) #expect(manager.tabs.map(\.id) == originalIDs) } diff --git a/cmuxTests/PortScannerPublicationTests.swift b/cmuxTests/PortScannerPublicationTests.swift index a874ef0529ac..7bf7749c989a 100644 --- a/cmuxTests/PortScannerPublicationTests.swift +++ b/cmuxTests/PortScannerPublicationTests.swift @@ -511,15 +511,21 @@ struct PortScannerAgentPortRetirementTests { await runner.stopListening() let firstLsofInvocation = await runner.lsofInvocationCount - var retiredPorts = initialPorts for expectedInvocation in (firstLsofInvocation + 1)...(firstLsofInvocation + 3) { scanner.refreshAgentPorts(workspaceId: workspaceID, agentRoots: [root]) try await runner.waitForLsofInvocation(expectedInvocation) - retiredPorts = try #require(await iterator.next()) scanner.queue.sync {} } - - #expect(retiredPorts.isEmpty) + // An unchanged port set is published only while the refresh's force + // flag survives, and the previous delivery's acknowledgement hops off + // the main actor before it reaches the scanner queue, so it can clear + // that flag under a later refresh. Retirement itself is a change and + // always publishes: drain until it lands instead of expecting one + // publication per refresh. + var retiredPorts = initialPorts + while !retiredPorts.isEmpty { + retiredPorts = try #require(await iterator.next()) + } let postExitRequestedPIDs = (await runner.lsofRequestedPIDs).dropFirst() #expect(postExitRequestedPIDs.allSatisfy { $0 == [100] }) diff --git a/cmuxTests/QuitConfirmationAlertPresenterTests.swift b/cmuxTests/QuitConfirmationAlertPresenterTests.swift index 8585ccff5634..8b5575149f3b 100644 --- a/cmuxTests/QuitConfirmationAlertPresenterTests.swift +++ b/cmuxTests/QuitConfirmationAlertPresenterTests.swift @@ -176,7 +176,12 @@ struct QuitConfirmationAlertPresenterTests { // and leaves two usable, non-overlapping controls. alert.window.displayIfNeeded() alert.window.contentView?.layoutSubtreeIfNeeded() - let buttonFrames = alert.buttons.map(\.frame) + // Compare alignment rects, not raw frames: where NSAlert stacks the + // buttons, each bezel button's frame carries transparent padding + // outside its visible control (e.g. frame (-6,-6,240,40) around a + // 228x28 control), so adjacent frames legitimately overlap in that + // padding while the controls themselves stay separated. + let buttonFrames = alert.buttons.map { $0.alignmentRect(forFrame: $0.frame) } #expect(buttonFrames.count == 2) #expect(alert.didLayoutWhileHidden) #expect(buttonFrames.allSatisfy { $0.width > 0 && $0.height > 0 }) diff --git a/cmuxTests/RemoteTmuxBonsplitImpositionRenderTests.swift b/cmuxTests/RemoteTmuxBonsplitImpositionRenderTests.swift index b9602ccd3cac..ffa1f4f73514 100644 --- a/cmuxTests/RemoteTmuxBonsplitImpositionRenderTests.swift +++ b/cmuxTests/RemoteTmuxBonsplitImpositionRenderTests.swift @@ -751,14 +751,42 @@ import Testing "sizing inputs drifted during the perturbation — this run judged an input change, not the liveness hole" ) + // The re-arm budget is bounded per input fixed point, so a failure + // reading `rearms=3` is ambiguous: either the recovery passes ran and + // failed to impose, or the budget was already spent before the + // perturbation and no recovery pass ran at all. Bracket the recovery + // window with the DEBUG pass/re-arm counters so the CI message names + // which one happened. + let rearmsSpentAtPerturbation = mirror.outputParityRearmsSpent + let sizingPassesBeforeRecovery = RemoteTmuxSizingDiagnostics.sizingPassCount + let parityRearmsBeforeRecovery = RemoteTmuxSizingDiagnostics.parityRearmCount + // A redundant trigger, exactly what the live app keeps delivering at // rest (surface samples, geometry echoes). Inputs are unchanged, so // today the pass early-returns and the views stay off-plan forever. mirror.setNeedsSizingPass() try await pump(60, until: { planViewMismatch(mirror) == nil }) + let sizingPassesDuringRecovery = RemoteTmuxSizingDiagnostics.sizingPassCount + - sizingPassesBeforeRecovery + let parityRearmsDuringRecovery = RemoteTmuxSizingDiagnostics.parityRearmCount + - parityRearmsBeforeRecovery + let plannedOuters = mirror.lastPlannedOuterSizes + .sorted { $0.key < $1.key } + .map { "%\($0.key)=\(Int($0.value.width))x\(Int($0.value.height))" } + .joined(separator: " ") + // Name the layer that kept the stale geometry: the live split view's + // arranged frames (did bonsplit apply the imposition?) and the split + // model (is the imposition still set?). planViewMismatch reads the + // portal-hosted terminal views, which follow those frames. + let arrangedWidths = firstDescendant(ofType: NSSplitView.self, in: hostingView) + .map { $0.arrangedSubviews.map { Int($0.frame.width) } } ?? [] + let splitModel: String = { + guard case .split(let split) = mirror.bonsplitController.treeSnapshot() else { return "leaf" } + return "pos=\(split.dividerPosition) imposed=\(split.imposedFirstExtent.map { "\($0)" } ?? "nil")" + }() #expect( planViewMismatch(mirror) == nil, - "off-plan geometry never re-converged: \(planViewMismatch(mirror) ?? ""); visible=\(mirror.isEffectivelyVisibleForSizing) drag=\(mirror.bonsplitController.isDividerDragActive) inFlight=\(mirror.dividerResizeInFlight != nil) scheduled=\(mirror.sizingPassScheduled) rearms=\(mirror.outputParityRearmsSpent) hasCompletedInputs=\(mirror.lastCompletedSizingInputs != nil)" + "off-plan geometry never re-converged: \(planViewMismatch(mirror) ?? ""); arranged=\(arrangedWidths) split=\(splitModel) visible=\(mirror.isEffectivelyVisibleForSizing) drag=\(mirror.bonsplitController.isDividerDragActive) inFlight=\(mirror.dividerResizeInFlight != nil) scheduled=\(mirror.sizingPassScheduled) rearms=\(mirror.outputParityRearmsSpent) rearmsAtPerturbation=\(rearmsSpentAtPerturbation) passesDuringRecovery=\(sizingPassesDuringRecovery) rearmsDuringRecovery=\(parityRearmsDuringRecovery) plan=[\(plannedOuters)] hasCompletedInputs=\(mirror.lastCompletedSizingInputs != nil)" ) withExtendedLifetime(connection) {} } diff --git a/cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift b/cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift index 9e084f85b9a5..042fd5fc89d1 100644 --- a/cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift +++ b/cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift @@ -165,17 +165,28 @@ struct RemoteTmuxMirrorPaneInputMappingTests { ) manager.selectWorkspace(harness.workspace) let panel = try #require(try harness.mirror().panel(forPane: 4)) + // A manual-I/O mirror pane spawns eagerly inside its hidden bootstrap + // window, which `TerminalSurface.uiWindow` deliberately excludes. The + // main window's portal adopts the pane host only once AppKit and + // SwiftUI get run-loop time, so physical key delivery has to wait for + // that adoption instead of assuming the runtime-ready wait spun the loop. + let window = try #require(AppDelegate.shared?.windowForMainWindowId(harness.windowId)) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() panel.hostedView.setVisibleInUI(true) panel.hostedView.setActive(true) panel.hostedView.layoutSubtreeIfNeeded() await waitForLiveSurface(panel.surface) + _ = await AppKitTestEventPump().waitUntil(timeout: .seconds(10)) { + panel.surface.uiWindow === window && panel.hostedView.surfaceView.window === window + } try #require( panel.surface.hasLiveSurface, "Remote manual-I/O key coverage requires a live Ghostty surface" ) try #require( - panel.surface.uiWindow != nil, - "Physical key coverage requires a usable window; hosted=\(String(describing: panel.hostedView.window)), live=\(panel.surface.hasLiveSurface)" + panel.surface.uiWindow === window, + "Physical key coverage requires the mirror pane in the main window; hosted=\(String(describing: panel.hostedView.window)), bootstrap=\(panel.surface.isHeadlessStartupWindow(panel.hostedView.window)), live=\(panel.surface.hasLiveSurface)" ) return panel.surface } diff --git a/cmuxTests/RemoteTmuxPaneSeedTransportTests.swift b/cmuxTests/RemoteTmuxPaneSeedTransportTests.swift index 8f623068c2db..86c2eb08816a 100644 --- a/cmuxTests/RemoteTmuxPaneSeedTransportTests.swift +++ b/cmuxTests/RemoteTmuxPaneSeedTransportTests.swift @@ -292,6 +292,12 @@ import Testing workspace: workspace ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7]) let snapshot = Data(repeating: UInt8(ascii: "x"), count: 9 * 1_024 * 1_024) sessionMirror.routeSeed( @@ -334,6 +340,12 @@ import Testing workspace: workspace ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7]) sessionMirror.routeSeed( paneId: 7, @@ -382,6 +394,12 @@ import Testing workspace: manager.selectedWorkspace! ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7]) sessionMirror.routeSeed( paneId: 7, @@ -784,6 +802,12 @@ import Testing pendingPaneSeedByteLimit: 10 ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7, 8], columns: 600, rows: 600) sessionMirror.routeSeed( paneId: 7, @@ -1591,6 +1615,31 @@ import Testing } } + /// Re-publishes `windowId` with pane grids far larger than any surface in + /// these tests has applied, keeping the pane ids and their order. Seed + /// delivery waits for the published grid, so this models tmux running + /// ahead of the local terminal without spinning the run loop. + private func publishLaggingPaneGrid( + on connection: RemoteTmuxControlConnection, + windowId: Int, + paneIds: [Int], + columns: Int = 400, + rows: Int = 200 + ) { + let leaves = paneIds.enumerated().map { index, paneId in + RemoteTmuxLayoutNode( + width: columns, height: rows, x: index * (columns + 1), y: 0, content: .pane(paneId) + ) + } + let width = paneIds.count * columns + max(0, paneIds.count - 1) + let layout = leaves.count == 1 + ? leaves[0] + : RemoteTmuxLayoutNode(width: width, height: rows, x: 0, y: 0, content: .horizontal(leaves)) + connection.windowsByID[windowId] = RemoteTmuxWindow( + id: windowId, width: width, height: rows, layout: layout + ) + } + private func attachedConnection( pendingPaneSeedByteLimit: Int = RemoteTmuxControlConnection.maximumPendingPaneSeedBytes ) -> Fixture { @@ -1892,6 +1941,12 @@ import Testing pendingPaneSeedByteLimit: 64 ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7]) sessionMirror.routeSeed( paneId: 7, @@ -1944,6 +1999,12 @@ import Testing pendingPaneSeedByteLimit: 64 ) defer { sessionMirror.detachObserver() } + // A manual-I/O mirror pane spawns eagerly in its hidden bootstrap + // window and renders the grid the mirror assigned it before this seed + // arrives, so a seed against that grid would be delivered on the spot. + // Publish a larger pane grid than any surface here has applied; the + // retention under test exists for exactly that lag. + publishLaggingPaneGrid(on: fixture.connection, windowId: 1, paneIds: [7]) sessionMirror.routeSeed( paneId: 7, diff --git a/cmuxTests/RemoteTmuxProjectedFocusInteractionTests.swift b/cmuxTests/RemoteTmuxProjectedFocusInteractionTests.swift index e23df2d5461d..a2ef433a8717 100644 --- a/cmuxTests/RemoteTmuxProjectedFocusInteractionTests.swift +++ b/cmuxTests/RemoteTmuxProjectedFocusInteractionTests.swift @@ -10,10 +10,6 @@ import Testing @MainActor final class RemoteTmuxPanePortalTestHarness { - private final class KeyStatusTestWindow: NSWindow { - override var isKeyWindow: Bool { true } - } - let window: NSWindow private let originalWindow: NSWindow? private let appDelegate: AppDelegate? diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index 6f3a3a836702..22ad3769c279 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -14,7 +14,9 @@ final class RightSidebarCommandPaletteTests: XCTestCase { let defaults = UserDefaults.standard defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) - defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) + // Cloud Machines defaults on in dev builds (d6584c07e0); pin the toggle off so + // the default-mode contract below is the same on every build. + defaults.set(false, forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) let context = CommandPaletteContextSnapshot() @@ -39,9 +41,7 @@ final class RightSidebarCommandPaletteTests: XCTestCase { } // Files/Find/Vault are always present; Machines follows the Cloud - // Machines beta toggle, which is off by default on every build - // (cleared above) unless a managed profile forces Cloud off, and - // feed/dock stay off. + // Machines beta toggle (pinned off above), and feed/dock stay off. let machinesAvailable = RightSidebarMode.machines.isAvailable() XCTAssertFalse(machinesAvailable) XCTAssertEqual(contributions.count, 3) diff --git a/cmuxTests/SSHConfiguredRemoteCommandHostTests.swift b/cmuxTests/SSHConfiguredRemoteCommandHostTests.swift index c68b0d626f97..54403a964830 100644 --- a/cmuxTests/SSHConfiguredRemoteCommandHostTests.swift +++ b/cmuxTests/SSHConfiguredRemoteCommandHostTests.swift @@ -145,6 +145,9 @@ struct SSHConfiguredRemoteCommandHostTests { case "workspace.remote.pty_bridge": return processSupport.v2Response(id: id, ok: true, result: [ "host": "127.0.0.1", + // ssh-pty-attach rejects a daemon whose version it cannot + // verify before it connects to the bridge (#12726). + "daemon_version": BundledCLITestSupport.appVersion, "port": bridge.port, "token": "bridge-token", "session_id": sessionID, diff --git a/cmuxTests/SSHDeepSleepReattachTests.swift b/cmuxTests/SSHDeepSleepReattachTests.swift index b3a3c4bf1124..8a311d2f467c 100644 --- a/cmuxTests/SSHDeepSleepReattachTests.swift +++ b/cmuxTests/SSHDeepSleepReattachTests.swift @@ -254,14 +254,18 @@ struct SSHDeepSleepReattachTests { @Test func confirmedCloudPTYExitRestartsWithInheritedCustomIdentity() throws { let workspace = Workspace() let initialPanel = try #require(workspace.focusedTerminalPanel) - workspace.configureRemoteConnection(Self.persistentCloudConfiguration(), autoConnect: false) let customSessionID = "cloud-custom-session" + // Once the workspace is Cloud-owned, a split carrying a launch override + // such as a custom PTY identity fails closed instead of spawning a + // local PTY (#13098). Create the custom-identity pane first; what this + // test pins is that identity surviving the confirmed exit and restart. let panel = try #require(workspace.newTerminalSplit( from: initialPanel.id, orientation: .horizontal, focus: false, remotePTYSessionID: customSessionID )) + workspace.configureRemoteConnection(Self.persistentCloudConfiguration(), autoConnect: false) #expect(panel.surface.respawnAdditionalEnvironment["CMUX_REMOTE_PTY_SESSION_ID"] == customSessionID) #expect(workspace.remotePTYSessionIDsByPanelId[panel.id] == customSessionID) diff --git a/cmuxTests/SSHRemoteCWDRegressionTests.swift b/cmuxTests/SSHRemoteCWDRegressionTests.swift index c133d60b55af..4a4a8c7b68db 100644 --- a/cmuxTests/SSHRemoteCWDRegressionTests.swift +++ b/cmuxTests/SSHRemoteCWDRegressionTests.swift @@ -107,7 +107,12 @@ struct SSHRemoteCWDRegressionTests { """) let encodedWorkingDirectory = Data(initialWorkingDirectory.path.utf8).base64EncodedString() - let script = RemoteInteractiveShellBootstrapBuilder.script(remoteRelayPort: 0, shellFeatures: "") + // The helper exec under test belongs to the persistent PTY attach + // launch (`SSHPTYAttachStartupCommandBuilder` passes + // `protectsFromHangup: true`); plain SSH and Mosh exec the shell directly. + let script = RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, shellFeatures: "", protectsFromHangup: true + ) .replacingOccurrences(of: "__CMUX_REMOTE_INITIAL_CWD_B64__", with: encodedWorkingDirectory) let result = runProcess( executablePath: "/usr/bin/env", @@ -117,7 +122,7 @@ struct SSHRemoteCWDRegressionTests { "CMUX_CAPTURE_PWD=\(capturedPWD.path)", "CMUX_PERSISTENT_PTY_EXEC_HELPER=\(helper.path)", "/bin/sh", "-c", script, ], - timeout: 5 + timeout: 30 ) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr)) @@ -172,7 +177,9 @@ struct SSHRemoteCWDRegressionTests { func bootstrap(for directory: URL) -> String { let encodedDirectory = Data(directory.path.utf8).base64EncodedString() - return RemoteInteractiveShellBootstrapBuilder.script(remoteRelayPort: 0, shellFeatures: "") + return RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, shellFeatures: "", protectsFromHangup: true + ) .replacingOccurrences(of: "__CMUX_REMOTE_INITIAL_CWD_B64__", with: encodedDirectory) } try writeExecutableShellFile(at: secondBootstrap, body: bootstrap(for: secondWorkingDirectory)) @@ -186,7 +193,7 @@ struct SSHRemoteCWDRegressionTests { "CMUX_SECOND_BOOTSTRAP=\(secondBootstrap.path)", "CMUX_PERSISTENT_PTY_EXEC_HELPER=\(helper.path)", "/bin/sh", "-c", bootstrap(for: firstWorkingDirectory), ], - timeout: 5 + timeout: 30 ) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr)) diff --git a/cmuxTests/SetAutoTitleSocketTests+CloudSidebar.swift b/cmuxTests/SetAutoTitleSocketTests+CloudSidebar.swift index 28c40f16df8c..dfb7c4f3527a 100644 --- a/cmuxTests/SetAutoTitleSocketTests+CloudSidebar.swift +++ b/cmuxTests/SetAutoTitleSocketTests+CloudSidebar.swift @@ -36,7 +36,7 @@ extension SetAutoTitleSocketTests { #expect(manager.setCustomTitle(tabId: workspace.id, title: "Review / 本番")) try await fixture.drain() #expect(fixture.provider.workspaceRenames == ["Review / 本番"]) - fixture.install(try fixture.state(revision: 3, name: "Calculate 2+2", workspaceName: "Review / 本番")) + fixture.install(try fixture.state(revision: 3, name: "Calculate 2+2", workspaceName: "Review / 本番", nameSource: "auto")) fixture.reconcile() #expect(workspace.title == "Review / 本番") try fixture.assertParity("Calculate 2+2", workspaceName: "Review / 本番") @@ -49,15 +49,21 @@ extension SetAutoTitleSocketTests { let fixture = try CloudSidebarRenameFixture(manager: manager, workspace: workspace, catalog: .shared) defer { fixture.close() } let context = try #require(fixture.catalog.cloudAgentNameContext(workspaceID: workspace.id, panelID: fixture.panelID)?.wire) - if userFirst { #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Arbitrary / 名前")) } - _ = try await callAsync(method: "surface.sync_codex_native_title", params: [ + if userFirst { + #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Arbitrary / 名前")) + try await fixture.drain() + fixture.install(try fixture.state(revision: 2, name: "Arbitrary / 名前", nameSource: "user")) + fixture.reconcile() + } + let agent = try await callAsync(method: "surface.sync_codex_native_title", params: [ "workspace_id": workspace.id.uuidString, "panel_id": fixture.panelID.uuidString, "title": "Calculate 2+2", "cloud_name_context": context ]) + #expect((agent["result"] as? [String: Any])?["applied"] as? Bool == !userFirst) if !userFirst { #expect(workspace.setPanelCustomTitle(panelId: fixture.panelID, title: "Arbitrary / 名前")) } try await fixture.drain() #expect(fixture.provider.tabRenames.last == "Arbitrary / 名前") - fixture.install(try fixture.state(revision: 4, name: "Arbitrary / 名前")) + fixture.install(try fixture.state(revision: 4, name: "Arbitrary / 名前", nameSource: "user")) fixture.reconcile() let delayed = try await callAsync(method: "surface.sync_codex_native_title", params: [ "workspace_id": workspace.id.uuidString, "panel_id": fixture.panelID.uuidString, @@ -65,7 +71,7 @@ extension SetAutoTitleSocketTests { ]) #expect((delayed["result"] as? [String: Any])?["applied"] as? Bool == false) try await fixture.drain() - fixture.install(try fixture.state(revision: 1, generation: "reconnected", name: "Arbitrary / 名前")) + fixture.install(try fixture.state(revision: 1, generation: "reconnected", name: "Arbitrary / 名前", nameSource: "user")) fixture.reconcile() try fixture.assertParity("Arbitrary / 名前") #expect(workspace.panelCustomTitleSources[fixture.panelID] == .remote) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 025618de6b1c..5bb5cf8083dc 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -860,7 +860,10 @@ final class CommandPaletteAuthCommandTests: XCTestCase { let visibleCommandIds = visibleAuthCommandIds(context) - XCTAssertEqual(visibleCommandIds, [ContentView.commandPaletteAuthSignOutCommandId]) + XCTAssertEqual(visibleCommandIds, [ + ContentView.commandPaletteAuthSignOutCommandId, + ContentView.commandPaletteAuthTeamPickerCommandId, + ]) } func testWorkingAuthContextHidesSignInAndSignOutCommands() { diff --git a/cmuxTests/SidebarHiddenPresentationTests.swift b/cmuxTests/SidebarHiddenPresentationTests.swift index e978c70e0536..3f004863c164 100644 --- a/cmuxTests/SidebarHiddenPresentationTests.swift +++ b/cmuxTests/SidebarHiddenPresentationTests.swift @@ -5,6 +5,24 @@ import SwiftUI import Testing @testable import cmux_DEV +/// Counts the async notifications that land inside a measured window, so a +/// failure can name the input that drove an extra sidebar body pass. +@MainActor +private final class RevealSignalLog { + private var countsByName: [String: Int] = [:] + + func record(_ name: Notification.Name) { + countsByName[name.rawValue, default: 0] += 1 + } + + var summary: String { + countsByName + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } +} + @Suite(.serialized) @MainActor struct SidebarHiddenPresentationTests { @@ -209,6 +227,7 @@ struct SidebarHiddenPresentationTests { let sidebarState = SidebarState() let notificationStore = TerminalNotificationStore.shared var revealRowInputProjections = 0 + var isMeasuringRevealInvalidations = false let root = ContentView( updateViewModel: UpdateStateModel(), windowId: UUID(), @@ -223,6 +242,7 @@ struct SidebarHiddenPresentationTests { .environment( \.sidebarLazyContractProbe, SidebarLazyContractProbe( + shouldTraceBodyChanges: { isMeasuringRevealInvalidations }, workspaceRowInputProjection: { revealRowInputProjections += 1 } ) ) @@ -285,9 +305,40 @@ struct SidebarHiddenPresentationTests { focusedWorkspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: true) _ = await cloudChangeIterator.next() + // A doubled projection count means a SECOND sidebar body pass followed + // the reveal. Record what landed inside the reveal window (the async + // inputs the hidden phase queued: the workspace's directory channel, + // workspace order, the shared agent index) and how many projections + // the first run-loop turn alone produced, so the failure names which + // input drove the extra pass instead of only reporting the count. + let revealSignals = RevealSignalLog() + let revealSignalNames: [Notification.Name] = [ + .workspaceCurrentDirectoryDidChange, + .workspaceOrderDidChange, + .sharedLiveAgentIndexDidChange, + ] + let revealSignalObservers = revealSignalNames.map { name in + NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { revealSignals.record(name) } + } + } + defer { + for observer in revealSignalObservers { + NotificationCenter.default.removeObserver(observer) + } + } revealRowInputProjections = 0 + isMeasuringRevealInvalidations = true + defer { isMeasuringRevealInvalidations = false } sidebarState.toggle() + await drainMainRunLoop(for: window, iterations: 1) + let projectionsAfterFirstRevealTurn = revealRowInputProjections await drainMainRunLoop(for: window) + isMeasuringRevealInvalidations = false let reopenedContainers = descendants( of: SidebarWorkspaceTableContainerView.self, in: window.contentView @@ -303,7 +354,11 @@ struct SidebarHiddenPresentationTests { ) #expect( revealRowInputProjections == tabManager.tabs.count, - "Reopening must project each current workspace row exactly once." + """ + Reopening must project each current workspace row exactly once. \ + firstTurn=\(projectionsAfterFirstRevealTurn) \ + signals=[\(revealSignals.summary)] + """ ) var cloudRow: SidebarWorkspaceRowTableCellView? let deadline = Date(timeIntervalSinceNow: 1) diff --git a/cmuxTests/SidebarWorkspaceTableTests.swift b/cmuxTests/SidebarWorkspaceTableTests.swift index 0e40a2d027cd..3148005211a3 100644 --- a/cmuxTests/SidebarWorkspaceTableTests.swift +++ b/cmuxTests/SidebarWorkspaceTableTests.swift @@ -167,7 +167,7 @@ struct SidebarWorkspaceTableTests { @Test @MainActor - func repeatedProvisionalReconstructionRetainsEveryContainerForTeardown() async throws { + func repeatedProvisionalReconstructionDetachesTablesWithoutWriters() async throws { let controller = SidebarWorkspaceTableController() let firstContainer = controller.makeContainerView() let row = makeRowConfiguration() @@ -186,16 +186,15 @@ struct SidebarWorkspaceTableTests { controller.dismantleContainerView(firstContainer) let secondContainer = controller.makeContainerView() controller.dismantleContainerView(secondContainer) + #expect(secondContainer.tableView.dataSource == nil) + #expect(secondContainer.tableView.delegate == nil) let thirdContainer = controller.makeContainerView() controller.dismantleContainerView(thirdContainer) - + #expect(thirdContainer.tableView.dataSource == nil) + #expect(thirdContainer.tableView.delegate == nil) controller.prepareForMouseDown() #expect(firstContainer.tableView.dataSource == nil) #expect(firstContainer.tableView.delegate == nil) - #expect(secondContainer.tableView.dataSource == nil) - #expect(secondContainer.tableView.delegate == nil) - #expect(thirdContainer.tableView.dataSource == nil) - #expect(thirdContainer.tableView.delegate == nil) _ = writer } diff --git a/cmuxTests/SimulatorPanelThemeTests.swift b/cmuxTests/SimulatorPanelThemeTests.swift index 85ff8493b844..cce63831cd45 100644 --- a/cmuxTests/SimulatorPanelThemeTests.swift +++ b/cmuxTests/SimulatorPanelThemeTests.swift @@ -63,7 +63,16 @@ struct SimulatorPanelVisibilityTests { root.addSubview(firstHost!) root.addSubview(secondHost) - let window = NSWindow( + // The pane only takes framebuffer demand while its host window is on + // screen, which `simulatorHostWindowIsVisible` reads from + // `NSWindow.occlusionState`. The window server only reports `.visible` + // for the active app's on-screen windows, and the app-host test process + // runs headless and is usually not the active app, so an ordered-in + // test window reports `isVisible == true` with no `.visible` occlusion + // bit. Pin the on-screen status this scenario is premised on, so the + // suite asserts the coordinator's host refcounting rather than the CI + // host's window server. + let window = OnScreenTestWindow( contentRect: root.bounds, styleMask: [.borderless], backing: .buffered, @@ -320,6 +329,16 @@ struct CanvasSimulatorPointerOwnershipTests { } } +/// A window that reports the occlusion state an on-screen host window has in +/// the running app. `simulatorHostWindowIsVisible` gates frame demand on +/// `occlusionState.contains(.visible)`, which the window server reports only +/// for the active app's on-screen windows; the headless app-host test process +/// is usually not the active app, so a window it orders in reports no +/// `.visible` bit and the Simulator pane never asks for a framebuffer. +private final class OnScreenTestWindow: NSWindow { + override var occlusionState: NSWindow.OcclusionState { .visible } +} + private actor SimulatorThemePaneClient: SimulatorPaneClient { private let events = SimulatorWorkerEventStreamSource( maximumBufferedBytes: 1_024, diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index 3e59b6a27777..01f149fe33b8 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -12,6 +12,8 @@ import Testing @Suite struct SurfaceCatalogTests { private struct TestTimeout: Error {} + /// Local destinations each test registers so ownership checks resolve a live workspace. + private let live = LiveWorkspaceFixture() @Test("Cloud rename ordering is shared across local windows") func cloudRenameCoordinatorSerializesOneRemoteIdentity() async { @@ -79,7 +81,7 @@ struct SurfaceCatalogTests { @Test("Explicit remote placement fails closed without view metadata") func explicitRemotePlacementFailsClosedWithoutViewMetadata() throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) let id = SurfaceResourceID(machine: machine, kind: .terminal, key: "term_1") @@ -98,7 +100,7 @@ struct SurfaceCatalogTests { @Test("Duplicate remote tab placement fails closed") func duplicateRemoteTabPlacementFailsClosed() throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) let id = SurfaceResourceID(machine: machine, kind: .terminal, key: "term_1") @@ -167,19 +169,39 @@ struct SurfaceCatalogTests { @MainActor private final class MaterializeGate { private(set) var entered = false - private var enteredContinuation: CheckedContinuation? + private var callerEnded = false + private var enteredContinuation: CheckedContinuation? private var releaseContinuations: [CheckedContinuation] = [] - func waitUntilEntered() async { - if entered { return } - await withCheckedContinuation { continuation in - enteredContinuation = continuation + /// Returns true once the provider enters `materialize`, or false when `caller` + /// finishes first because it threw before reaching the provider. Without the + /// second signal that setup failure parks the test until the suite time limit, + /// and every time-limit hit relaunches the whole app host. + func waitUntilEntered(orEndOf caller: Task) async -> Bool { + if entered { return true } + Task { @MainActor [weak self] in + _ = await caller.result + self?.finishCaller() + } + return await withCheckedContinuation { continuation in + if entered || callerEnded { + continuation.resume(returning: entered) + } else { + enteredContinuation = continuation + } } } + private func finishCaller() { + callerEnded = true + guard !entered, let continuation = enteredContinuation else { return } + enteredContinuation = nil + continuation.resume(returning: false) + } + func block() async { entered = true - enteredContinuation?.resume() + enteredContinuation?.resume(returning: true) enteredContinuation = nil await withCheckedContinuation { continuation in releaseContinuations.append(continuation) @@ -270,7 +292,7 @@ struct SurfaceCatalogTests { @Test("Opening a remote pane preserves its selected tab and original tab order") func layoutProjectionPreservesSelectedTabOrder() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.device(SurfaceDeviceInstanceID(deviceID: UUID().uuidString, tag: "layout-test")) let provider = FakeProvider(machine: machine) catalog.register(provider) @@ -283,7 +305,7 @@ struct SurfaceCatalogTests { } catalog.replaceResources(resources, on: machine, from: provider) let placements = resources.map { SurfaceResourcePlacement(resource: $0.id, remoteView: $0.remoteViews?.first) } - let workspaceID = UUID() + let workspaceID = live.id() _ = try await catalog.projectGroupAsNewLocalWorkspace( SurfaceResourceGroup(title: "remote", placements: placements, remoteWorkspaceID: workspace.id), title: "remote", focus: false, @@ -304,7 +326,7 @@ struct SurfaceCatalogTests { @Test("Cloud delta patch preserves unaffected capability rows") func cloudDeltaPatchPreservesUnaffectedRows() throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) @@ -362,7 +384,7 @@ struct SurfaceCatalogTests { @Test("Cloud unavailable replacement keeps the reverse resource index exact") func cloudUnavailableReplacementKeepsResourceIndexExact() throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) @@ -392,7 +414,7 @@ struct SurfaceCatalogTests { @Test("Stale machine metadata cannot regress the accepted cloud workspace graph") func staleMachineMetadataPreservesCanonicalWorkspaceNames() throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) let snapshot: [String: Any] = [ @@ -429,8 +451,14 @@ struct SurfaceCatalogTests { let machine = SurfaceMachineID.device( SurfaceDeviceInstanceID(deviceID: "3f2504e0-4f89-11d3-9a0c-0305e82c3301", tag: "default") ) - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) + // The tree lists a device's workspaces and terminals only behind a + // trusted live link (e3d424c722); an untrusted device keeps just its + // header row, which would hide the directories this test checks. + provider.info.presence = SurfaceDevicePresence( + state: .online, lastSeenAt: nil, tag: "default", bundleID: nil, accountTrust: .sameAccount + ) catalog.register(provider) var resource = terminal(machine, "term_1", title: "~") resource.remoteWorkspace = SurfaceRemoteWorkspace(id: "ws", name: "~", index: 0, focused: true) @@ -438,7 +466,7 @@ struct SurfaceCatalogTests { let directories: [String?] = ["/Users/remote", "/Users/remote/project", nil] for directory in directories { resource.detail = directory - catalog.replaceResources([resource], on: machine, from: provider) + catalog.replaceResources([resource], on: machine, info: provider.info, from: provider) let snapshot = catalog.snapshot let presented = try #require(snapshot.resources.first { $0.id == resource.id }) @@ -464,13 +492,13 @@ struct SurfaceCatalogTests { // tab is reopened), nothing else republishes, so the catalog must ask // the provider itself; a record whose resource is still unpublished // stays staged until that provider's next publish resolves it. - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID(rawValue: "device:6f0d1c5e-2b5a-4d6e-9c1a-1c2d3e4f5a6b@nightly") let provider = FakeProvider(machine: machine) catalog.register(provider) let published = terminal(machine, "6C272F23-5E1F-45DB-A7DB-874F94C34E86") catalog.replaceResources([published], on: machine) - let workspaceID = UUID() + let workspaceID = live.id() let restoredPanelID = UUID() catalog.restore( [SurfaceProjectionRecord(panelID: restoredPanelID, resource: published.id, remoteWorkspaceID: nil, remoteTabID: nil)], @@ -498,7 +526,7 @@ struct SurfaceCatalogTests { } @Test func `Project materializes once and reuses the open pane`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(provider) let term = terminal(.cloud("vivid-newt"), "term_1") @@ -506,14 +534,14 @@ struct SurfaceCatalogTests { var focused: [SurfaceProjection] = [] catalog.focusProjection = { focused.append($0) } - let ws = UUID() + let ws = live.id() let first = try await catalog.project(term.id, into: .workspace(id: ws, placement: .split)) #expect(!first.reused) #expect(provider.materialized.count == 1) #expect(catalog.projections(of: term.id).count == 1) #expect(catalog.snapshot.isOpen(term.id)) - let second = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .tab)) + let second = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .tab)) #expect(second.reused) #expect(second.projection == first.projection) #expect(provider.materialized.count == 1, "reuse must not materialize a second pane") @@ -525,7 +553,7 @@ struct SurfaceCatalogTests { } @Test func `Workspace-scoped reuse ignores panes in other workspaces`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(provider) let term = terminal(.cloud("vivid-newt"), "display_like") @@ -533,8 +561,8 @@ struct SurfaceCatalogTests { var focused: [SurfaceProjection] = [] catalog.focusProjection = { focused.append($0) } - let wsA = UUID() - let wsB = UUID() + let wsA = live.id() + let wsB = live.id() let a = try await catalog.project(term.id, into: .workspace(id: wsA, placement: .split), reuseInWorkspace: wsA) #expect(!a.reused) @@ -553,23 +581,23 @@ struct SurfaceCatalogTests { #expect(focused == [b.projection]) // …and an unscoped call keeps the global open-or-focus jump. - let global = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .tab)) + let global = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .tab)) #expect(global.reused) #expect(provider.materialized.count == 2) } @Test func `Concurrent reuse waits for the in-flight materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() provider.materializeGate = gate catalog.register(provider) let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let destination = SurfaceDestination.workspace(id: UUID(), placement: .split) + let destination = SurfaceDestination.workspace(id: live.id(), placement: .split) let first = Task { try await catalog.project(term.id, into: destination) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: first), "the project call ended before reaching the provider") let (secondStarted, secondStartedContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) @@ -592,17 +620,17 @@ struct SurfaceCatalogTests { } @Test func `An adopted projection wins a materialization race`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() provider.materializeGate = gate catalog.register(provider) let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let destination = SurfaceDestination.workspace(id: UUID(), placement: .split) + let destination = SurfaceDestination.workspace(id: live.id(), placement: .split) let project = Task { try await catalog.project(term.id, into: destination) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: project), "the project call ended before reaching the provider") let adopted = SurfaceProjection(resource: term.id, workspaceID: UUID(), panelID: UUID()) catalog.record(adopted) gate.release() @@ -616,7 +644,7 @@ struct SurfaceCatalogTests { } @Test func `Cancelling the last project caller detaches without leaking a late materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) let (discarded, discardedContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) @@ -631,8 +659,8 @@ struct SurfaceCatalogTests { let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let project = Task { try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) } - await gate.waitUntilEntered() + let project = Task { try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } + try #require(await gate.waitUntilEntered(orEndOf: project), "the project call ended before reaching the provider") let (cancellationResult, cancellationResultContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) @@ -660,7 +688,7 @@ struct SurfaceCatalogTests { } @Test func `Cancellation at provider completion discards an unclaimed projection`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() provider.materializeGate = gate @@ -670,10 +698,10 @@ struct SurfaceCatalogTests { var project: Task? let task = Task { @MainActor in - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } project = task - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: task), "the project call ended before reaching the provider") provider.onMaterialize = { project?.cancel() } gate.release() @@ -686,7 +714,7 @@ struct SurfaceCatalogTests { } @Test func `A removed local resource is not resurrected by a late materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .local) provider.materializationPreserved = true let gate = MaterializeGate() @@ -697,9 +725,9 @@ struct SurfaceCatalogTests { provider.onMaterialize = { catalog.remove(term.id) } let project = Task { @MainActor in - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: project), "the project call ended before reaching the provider") gate.release() await #expect(throws: SurfaceCatalogError.unknownResource(term.id)) { @@ -710,7 +738,7 @@ struct SurfaceCatalogTests { } @Test func `A preserving materialization remains recorded when its caller cancels`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .local) provider.materializationPreserved = true let gate = MaterializeGate() @@ -721,10 +749,10 @@ struct SurfaceCatalogTests { var project: Task? let task = Task { @MainActor in - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } project = task - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: task), "the project call ended before reaching the provider") provider.onMaterialize = { project?.cancel() } gate.release() @@ -749,7 +777,8 @@ struct SurfaceCatalogTests { let catalog = SurfaceCatalog( abandonedMaterializationTimeout: .seconds(30), retiredMaterializationRetention: .seconds(30), - materializationClock: clock + materializationClock: clock, + cloudWorkspaceRenameService: live.renameService ) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let (discarded, discardedContinuation) = AsyncStream.makeStream( @@ -765,8 +794,8 @@ struct SurfaceCatalogTests { let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let first = Task { try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) } - await gate.waitUntilEntered() + let first = Task { try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } + try #require(await gate.waitUntilEntered(orEndOf: first), "the project call ended before reaching the provider") first.cancel() await #expect(throws: CancellationError.self) { try await first.value @@ -775,7 +804,7 @@ struct SurfaceCatalogTests { let replacementProvider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(replacementProvider) let replacement = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } defer { replacement.cancel() @@ -803,7 +832,7 @@ struct SurfaceCatalogTests { } @Test func `Unregistering cancels in-flight materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() provider.materializeGate = gate @@ -811,8 +840,8 @@ struct SurfaceCatalogTests { let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let project = Task { try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) } - await gate.waitUntilEntered() + let project = Task { try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } + try #require(await gate.waitUntilEntered(orEndOf: project), "the project call ended before reaching the provider") catalog.unregister(machine: .cloud("vivid-newt")) gate.release() @@ -823,7 +852,7 @@ struct SurfaceCatalogTests { } @Test func `Registering a replacement provider retires its old materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() oldProvider.materializeGate = gate @@ -832,9 +861,9 @@ struct SurfaceCatalogTests { catalog.replaceResources([term], on: .cloud("vivid-newt")) let oldProject = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: oldProject), "the project call ended before reaching the provider") let replacementProvider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(replacementProvider) @@ -843,7 +872,7 @@ struct SurfaceCatalogTests { } let newProject = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } gate.release() let result = try await newProject.value @@ -853,7 +882,7 @@ struct SurfaceCatalogTests { } @Test func `Tracked materialization capacity bounds permanently detached work per machine`() async throws { - let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1) + let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1, cloudWorkspaceRenameService: live.renameService) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() oldProvider.materializeGate = gate @@ -862,9 +891,9 @@ struct SurfaceCatalogTests { catalog.replaceResources([term], on: .cloud("vivid-newt")) let oldProject = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: oldProject), "the project call ended before reaching the provider") oldProject.cancel() await #expect(throws: CancellationError.self) { try await oldProject.value @@ -873,7 +902,7 @@ struct SurfaceCatalogTests { let second = terminal(.cloud("vivid-newt"), "term_2") catalog.upsert(second) await #expect(throws: SurfaceCatalogError.unavailable(second.id, reason: "materialization capacity exhausted")) { - try await catalog.project(second.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(second.id, into: .workspace(id: live.id(), placement: .split)) } gate.release() @@ -881,7 +910,7 @@ struct SurfaceCatalogTests { } @Test func `Tracked materialization capacity is isolated per machine`() async throws { - let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1) + let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1, cloudWorkspaceRenameService: live.renameService) let stuckProvider = FakeProvider(machine: .cloud("stuck")) let gate = MaterializeGate() stuckProvider.materializeGate = gate @@ -890,9 +919,9 @@ struct SurfaceCatalogTests { catalog.replaceResources([stuckTerm], on: .cloud("stuck")) let stuckProject = Task { - try await catalog.project(stuckTerm.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(stuckTerm.id, into: .workspace(id: live.id(), placement: .split)) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: stuckProject), "the project call ended before reaching the provider") stuckProject.cancel() await #expect(throws: CancellationError.self) { try await stuckProject.value @@ -902,7 +931,7 @@ struct SurfaceCatalogTests { catalog.register(healthyProvider) let healthyTerm = terminal(.cloud("healthy"), "term_1") catalog.replaceResources([healthyTerm], on: .cloud("healthy")) - let result = try await catalog.project(healthyTerm.id, into: .workspace(id: UUID(), placement: .split)) + let result = try await catalog.project(healthyTerm.id, into: .workspace(id: live.id(), placement: .split)) #expect(result.projection.resource == healthyTerm.id) #expect(healthyProvider.materialized.count == 1) @@ -910,7 +939,7 @@ struct SurfaceCatalogTests { } @Test func `Tracked materialization capacity spans provider replacement`() async throws { - let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1) + let catalog = SurfaceCatalog(maximumTrackedMaterializations: 1, cloudWorkspaceRenameService: live.renameService) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() oldProvider.materializeGate = gate @@ -919,9 +948,9 @@ struct SurfaceCatalogTests { catalog.replaceResources([term], on: .cloud("vivid-newt")) let oldProject = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } - await gate.waitUntilEntered() + try #require(await gate.waitUntilEntered(orEndOf: oldProject), "the project call ended before reaching the provider") oldProject.cancel() await #expect(throws: CancellationError.self) { try await oldProject.value @@ -930,7 +959,7 @@ struct SurfaceCatalogTests { let replacementProvider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(replacementProvider) await #expect(throws: SurfaceCatalogError.unavailable(term.id, reason: "materialization capacity exhausted")) { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } gate.release() @@ -949,7 +978,8 @@ struct SurfaceCatalogTests { abandonedMaterializationTimeout: .seconds(30), retiredMaterializationRetention: .seconds(30), maximumTrackedMaterializations: 1, - materializationClock: clock + materializationClock: clock, + cloudWorkspaceRenameService: live.renameService ) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let oldGate = MaterializeGate() @@ -959,9 +989,9 @@ struct SurfaceCatalogTests { catalog.replaceResources([term], on: .cloud("vivid-newt")) let oldProject = Task { - try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } - await oldGate.waitUntilEntered() + try #require(await oldGate.waitUntilEntered(orEndOf: oldProject), "the project call ended before reaching the provider") oldProject.cancel() await #expect(throws: CancellationError.self) { try await oldProject.value @@ -974,7 +1004,7 @@ struct SurfaceCatalogTests { catalog.register(replacementProvider) let replacement = try await catalog.project( term.id, - into: .workspace(id: UUID(), placement: .split) + into: .workspace(id: live.id(), placement: .split) ) #expect(!replacement.reused) #expect(replacementProvider.materialized.count == 1) @@ -983,7 +1013,7 @@ struct SurfaceCatalogTests { } @Test func `A replacement provider does not join retired materialization`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let oldProvider = FakeProvider(machine: .cloud("vivid-newt")) let gate = MaterializeGate() oldProvider.materializeGate = gate @@ -991,8 +1021,8 @@ struct SurfaceCatalogTests { let term = terminal(.cloud("vivid-newt"), "term_1") catalog.replaceResources([term], on: .cloud("vivid-newt")) - let oldProject = Task { try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) } - await gate.waitUntilEntered() + let oldProject = Task { try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } + try #require(await gate.waitUntilEntered(orEndOf: oldProject), "the project call ended before reaching the provider") catalog.unregister(machine: .cloud("vivid-newt")) await #expect(throws: SurfaceCatalogError.unknownResource(term.id)) { try await oldProject.value @@ -1001,7 +1031,7 @@ struct SurfaceCatalogTests { let newProvider = FakeProvider(machine: .cloud("vivid-newt")) catalog.register(newProvider) catalog.replaceResources([term], on: .cloud("vivid-newt")) - let newProject = Task { try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) } + let newProject = Task { try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) } gate.release() let result = try await newProject.value @@ -1011,12 +1041,12 @@ struct SurfaceCatalogTests { } @Test func `Ending a projection keeps the remote resource and tells the provider`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("m")) catalog.register(provider) let term = terminal(.cloud("m"), "term_1") catalog.replaceResources([term], on: .cloud("m")) - let projection = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)).projection + let projection = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)).projection catalog.endProjections(panelID: projection.panelID) #expect(catalog.projections(of: term.id).isEmpty) @@ -1025,12 +1055,12 @@ struct SurfaceCatalogTests { } @Test func `Moving a pane moves its projection`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .local) catalog.register(provider) let term = terminal(.local, "ABC") catalog.replaceResources([term], on: .local) - let projection = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)).projection + let projection = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)).projection let other = UUID() catalog.moveProjections(panelID: projection.panelID, to: other) #expect(catalog.projection(forPanel: projection.panelID)?.workspaceID == other) @@ -1039,15 +1069,15 @@ struct SurfaceCatalogTests { @Test("A second save while a Mac is disconnected preserves its remote projection") func pendingMacProjectionSurvivesAnotherSave() { let machine = SurfaceMachineID.device(SurfaceDeviceInstanceID(deviceID: UUID().uuidString, tag: "restore-test")) - let catalog = SurfaceCatalog() - let workspace = UUID() + let catalog = SurfaceCatalog(live: live) + let workspace = live.id() let record = SurfaceProjectionRecord( panelID: UUID(), resource: SurfaceResourceID(machine: machine, kind: .terminal, key: UUID().uuidString), remoteWorkspaceID: "mac-workspace", remoteTabID: "mac-tab" ) catalog.restore([record], workspaceID: workspace) #expect(catalog.projectionRecords(forWorkspace: workspace) == [record]) - let secondLaunch = SurfaceCatalog() + let secondLaunch = SurfaceCatalog(live: live) secondLaunch.restore(catalog.projectionRecords(forWorkspace: workspace), workspaceID: workspace) #expect(secondLaunch.projectionRecords(forWorkspace: workspace) == [record]) } @@ -1063,21 +1093,26 @@ struct SurfaceCatalogTests { snapshot.surfaceProjections = [SurfaceProjectionRecord( panelID: savedPanel.id, resource: resource, remoteWorkspaceID: "mac-workspace", remoteTabID: "mac-tab" )] - let restored = Workspace() - let remap = restored.restoreSessionSnapshot(snapshot) - let panelID = try #require(remap[savedPanel.id]) - let panel = try #require(restored.terminalPanel(for: panelID)) - #expect(panel.surface.ioMode == .manualMirror) - let savedAgain = restored.sessionSnapshot(includeScrollback: false) - #expect(savedAgain.surfaceProjections?.first?.resource == resource) - #expect(savedAgain.surfaceProjections?.first?.remoteWorkspaceID == "mac-workspace") + // The shared catalog restores only into a workspace the app can resolve. + let manager = TabManager() + let restored = try #require(manager.selectedWorkspace) + defer { manager.finalizeAllWorkspacesForWindowClose() } + try LiveWorkspaceFixture.withAppRegistration(of: manager) { + let remap = restored.restoreSessionSnapshot(snapshot) + let panelID = try #require(remap[savedPanel.id]) + let panel = try #require(restored.terminalPanel(for: panelID)) + #expect(panel.surface.ioMode == .manualMirror) + let savedAgain = restored.sessionSnapshot(includeScrollback: false) + #expect(savedAgain.surfaceProjections?.first?.resource == resource) + #expect(savedAgain.surfaceProjections?.first?.remoteWorkspaceID == "mac-workspace") + } } @Test func `Restored projections resolve when the provider reports the resource`() { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("m")) catalog.register(provider) - let ws = UUID(), panel = UUID() + let ws = live.id(), panel = UUID() let id = SurfaceResourceID(machine: .cloud("m"), kind: .terminal, key: "term_9") catalog.restore([SurfaceProjectionRecord(panelID: panel, resource: id)], workspaceID: ws) #expect(!catalog.snapshot.isOpen(id), "unknown until the link reports it") @@ -1088,7 +1123,7 @@ struct SurfaceCatalogTests { } @Test func `Snapshot orders local first, then by name and workspace index`() { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) catalog.register(FakeProvider(machine: .cloud("zeta"))) catalog.register(FakeProvider(machine: .cloud("alpha"))) catalog.register(FakeProvider(machine: .local)) @@ -1101,14 +1136,14 @@ struct SurfaceCatalogTests { } @Test func `Opening a group as a new workspace lays every resource out as its own pane`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("vm-1") let provider = FakeProvider(machine: machine) catalog.register(provider) let ids = ["a", "b", "c", "d"].map { SurfaceResourceID(machine: machine, kind: .terminal, key: $0) } catalog.replaceResources(ids.map { terminal(machine, $0.key) }, on: machine) - let newWorkspace = UUID() + let newWorkspace = live.id() let starter = UUID() var created: [String] = [] var closedStarters: [(UUID, UUID)] = [] @@ -1138,10 +1173,10 @@ struct SurfaceCatalogTests { } @Test func `Opening an unknown group as a new workspace closes the empty workspace again`() async { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("vm-1") catalog.register(FakeProvider(machine: machine)) - let starter = UUID(), newWorkspace = UUID() + let starter = UUID(), newWorkspace = live.id() var closedStarters = 0 let host = SurfaceCatalog.NewWorkspaceHost( create: { _ in (newWorkspace, starter) }, @@ -1159,7 +1194,7 @@ struct SurfaceCatalogTests { } @Test func openingLayoutKeepsUnplacedGroupResources() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("vm-layout") let provider = FakeProvider(machine: machine) catalog.register(provider) @@ -1174,7 +1209,7 @@ struct SurfaceCatalogTests { first: .leaf(placements: [members[0]]), second: .leaf(placements: [members[1]]) ) - let workspace = UUID() + let workspace = live.id() let host = SurfaceCatalog.NewWorkspaceHost( create: { _ in (workspace, nil) }, paneLookup: { _, panel in panel.uuidString }, @@ -1189,13 +1224,13 @@ struct SurfaceCatalogTests { } @Test func `Unregistering a machine drops its resources and projections`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("m")) catalog.register(provider) let term = terminal(.cloud("m"), "term_1") catalog.replaceResources([term], on: .cloud("m")) #expect(catalog.hasResources(on: .cloud("m"))) - _ = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)) + _ = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)) catalog.unregister(machine: .cloud("m")) #expect(catalog.snapshot.resources.isEmpty) #expect(!catalog.hasResources(on: .cloud("m"))) @@ -1204,7 +1239,7 @@ struct SurfaceCatalogTests { } @Test func `A late refresh from a deleted machine cannot resurrect catalog state`() { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("gone") let provider = FakeProvider(machine: machine) let term = terminal(machine, "term_late") @@ -1225,7 +1260,7 @@ struct SurfaceCatalogTests { } @Test func `A retired provider cannot write through its replacement`() { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("reused") let retired = FakeProvider(machine: machine) let replacement = FakeProvider(machine: machine) @@ -1252,7 +1287,7 @@ struct SurfaceCatalogTests { } @Test func `Unregister removes pending restores before a machine ID is reused`() { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("reused") let original = FakeProvider(machine: machine) let replacement = FakeProvider(machine: machine) @@ -1261,7 +1296,7 @@ struct SurfaceCatalogTests { let record = SurfaceProjectionRecord(panelID: panelID, resource: resourceID) catalog.register(original) - catalog.restore([record], workspaceID: UUID()) + catalog.restore([record], workspaceID: live.id()) #expect(catalog.pendingRestoredMachineIDs == Set(["reused"])) catalog.unregister(machine: machine) @@ -1275,16 +1310,16 @@ struct SurfaceCatalogTests { } @Test func `Unregistering a machine closes its display and browser panes but not terminals`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: .cloud("m")) catalog.register(provider) let term = terminal(.cloud("m"), "term_1") let display = SurfaceResource(id: SurfaceResourceID(machine: .cloud("m"), kind: .display, key: "display:1"), title: "Desktop", detail: "noVNC", lifecycle: .running, agent: nil, remoteWorkspace: nil, port: 6901, url: nil) let browser = SurfaceResource(id: SurfaceResourceID(machine: .cloud("m"), kind: .browser, key: "port:3000"), title: ":3000", detail: nil, lifecycle: .running, agent: nil, remoteWorkspace: nil, port: 3000, url: nil) catalog.replaceResources([term, display, browser], on: .cloud("m")) - let termProjection = try await catalog.project(term.id, into: .workspace(id: UUID(), placement: .split)).projection - let displayProjection = try await catalog.project(display.id, into: .workspace(id: UUID(), placement: .split)).projection - let browserProjection = try await catalog.project(browser.id, into: .workspace(id: UUID(), placement: .split)).projection + let termProjection = try await catalog.project(term.id, into: .workspace(id: live.id(), placement: .split)).projection + let displayProjection = try await catalog.project(display.id, into: .workspace(id: live.id(), placement: .split)).projection + let browserProjection = try await catalog.project(browser.id, into: .workspace(id: live.id(), placement: .split)).projection catalog.unregister(machine: .cloud("m")) @@ -1301,7 +1336,7 @@ struct SurfaceCatalogTests { @Test func `Delete workspace kills its viewed terminals first, spares the rest`() async throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) let doomedWorkspace = SurfaceRemoteWorkspace(id: "ws_1", name: "build", index: 0, focused: false) @@ -1328,7 +1363,7 @@ struct SurfaceCatalogTests { @Test func `Delete of an empty workspace closes it and kills nothing`() async throws { let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let provider = FakeProvider(machine: machine) catalog.register(provider) catalog.replaceResources([ @@ -1351,7 +1386,7 @@ extension SurfaceCatalogTests { /// Reserves the whole Cloud layout before attaching any terminal. @Test @MainActor func `Opening a workspace optimistically reserves the whole layout first and attaches every pane`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("vm-1") catalog.register(FakeProvider(machine: machine)) let ids = ["a", "b", "c", "d"].map { SurfaceResourceID(machine: machine, kind: .terminal, key: $0) } @@ -1360,7 +1395,7 @@ extension SurfaceCatalogTests { terminal(machine, $0.key, remoteView: SurfaceRemoteView(tabID: "tab-\($0.key)", workspace: remoteWorkspace)) }, on: machine) let placements = ids.map { SurfaceResourcePlacement(resource: $0, remoteWorkspaceID: remoteWorkspace.id, remoteTabID: "tab-\($0.key)") } - let newWorkspace = UUID() + let newWorkspace = live.id() let starter = UUID() var reserved: [(SurfaceDestination, Bool)] = [] var attached: [SurfaceResourceID] = [] @@ -1419,15 +1454,16 @@ extension SurfaceCatalogTests { /// A group with anything but known cloud terminals keeps the awaited path. @Test @MainActor func `Optimistic hosts fall back to awaited projection for groups with unknown resources`() async throws { - let catalog = SurfaceCatalog() + let catalog = SurfaceCatalog(live: live) let machine = SurfaceMachineID.cloud("vm-1") let provider = FakeProvider(machine: machine) catalog.register(provider) let known = SurfaceResourceID(machine: machine, kind: .terminal, key: "a") catalog.replaceResources([terminal(machine, "a")], on: machine) + let newWorkspace = live.id() var reservations = 0 let host = SurfaceCatalog.NewWorkspaceHost( - create: { _ in (UUID(), nil) }, + create: { _ in (newWorkspace, nil) }, paneLookup: { _, _ in nil }, closeStarter: { _, _ in }, optimistic: SurfaceCatalog.OptimisticPaneHost( diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 6fefcbcb8c7e..3c2cef923e6b 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -187,7 +187,7 @@ import SwiftUI let failure = try #require(workspace.cloudMaterializationFailures[panelID]) #expect(panelID == pendingPanelID) #expect(workspace.cloudPendingCreations[panelID]?.machine == machine) - #expect(failure.detail == error.errorDescription) + #expect(failure.detail == CloudDiagnosticFailure.classify(error).label) #expect(presentation.showsReconnectButton) let operation = try #require(recorder.operations.first) #expect(operation.operation == .terminal) @@ -255,6 +255,13 @@ import SwiftUI await Task.yield() } let overlay = try #require(card()) + let layoutDeadline = ContinuousClock.now + .seconds(3) + while (overlay.frame.width <= 100 || overlay.frame.height <= 50), ContinuousClock.now < layoutDeadline { + window.displayIfNeeded() + target.container.layoutSubtreeIfNeeded() + overlay.layoutSubtreeIfNeeded() + await Task.yield() + } #expect(overlay.frame.width > 100 && overlay.frame.height > 50) let terminalFrame = target.container.convert(source.hostedView.bounds, from: source.hostedView) #expect(abs(overlay.frame.midX - terminalFrame.midX) < 2) diff --git a/cmuxTests/SurfaceProjectionIdentityTests.swift b/cmuxTests/SurfaceProjectionIdentityTests.swift index b2b0cd6c9cb7..0ec6d8852c09 100644 --- a/cmuxTests/SurfaceProjectionIdentityTests.swift +++ b/cmuxTests/SurfaceProjectionIdentityTests.swift @@ -91,7 +91,10 @@ struct SurfaceProjectionIdentityTests { for id in Array(source.panels.keys) { _ = source.closePanel(id, force: true) } let remapped = restored.restoreSessionSnapshot(snapshot) let newPanelID = try #require(remapped[oldPanelID]) - #expect(newPanelID != oldPanelID) + // The persisted runtime id is reused whenever no live surface still holds it + // (aff0e32e93): the panel id is the ghostty surface id, so agent bindings survive + // relaunch. Only a collision mints a fresh id; either way the pane must be live. + #expect(restored.panels[newPanelID] != nil) let catalog = await localCatalog([restored]) let export = await read(catalog, workspaces: [restored]) let row = try projectionRow(export, panelID: newPanelID) @@ -171,12 +174,16 @@ struct SurfaceProjectionIdentityTests { let panel = try terminal(in: workspace) let catalog = await localCatalog(workspaces) let machine = SurfaceMachineID.cloud("identity-fixture") + // The catalog ignores writes about a Cloud machine with no registered provider + // (a22bde65ad), so register the fixture provider before publishing its resource. + let provider = try CloudCatalogQueryTestProvider(machine: machine, catalog: catalog) + catalog.register(provider) let resourceID = SurfaceResourceID(machine: machine, kind: .terminal, key: "daemon-terminal") let resource = SurfaceResource(id: resourceID, title: "same title", detail: nil, lifecycle: .running, agent: nil, remoteWorkspace: nil, port: nil, url: nil) var info = LocalSurfaceProvider(catalog: catalog, workspaces: { workspaces }).info info.id = machine info.linkState = .connected - catalog.replaceResources([resource], on: machine, info: info) + catalog.replaceResources([resource], on: machine, info: info, from: provider) catalog.record(SurfaceProjection(resource: resourceID, workspaceID: workspace.id, panelID: panel.id, remoteWorkspaceID: "daemon-workspace", remoteTabID: "daemon-tab")) let export = await read(catalog, workspaces: workspaces) for cloudOnly in [false, true] { diff --git a/cmuxTests/SurfaceSocketCommandTests.swift b/cmuxTests/SurfaceSocketCommandTests.swift index 1c8bd111e725..c3dd4304461b 100644 --- a/cmuxTests/SurfaceSocketCommandTests.swift +++ b/cmuxTests/SurfaceSocketCommandTests.swift @@ -237,6 +237,14 @@ struct SurfaceSocketCommandTests { let betaKey = RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey let previousBeta = UserDefaults.standard.object(forKey: betaKey) let app = try VaultPaneAppFixture() + // `vm.workspace_new` admits its optimistic local workspace through the + // active main window before it asks the provider for anything; a + // context without a window is pruned and the call is cancelled. Bind + // a bare window the way the shortcut tests do. + let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(app.windowID.uuidString)") + app.appDelegate.mainWindowContexts.values.first { $0.windowId == app.windowID }?.window = window + defer { withExtendedLifetime(window) {} } defer { app.tearDown() TerminalController.shared.setActiveTabManager(previousManager) @@ -552,7 +560,10 @@ struct SurfaceSocketCommandTests { let response = try await Self.call("vm.workspace_new", ["id": fixture.machineID, "name": "feature"]) #expect(fixture.provider.mutations.first == "workspace create feature") - #expect(fixture.provider.refreshes == 0, "creation consumes its receipt without a blocking snapshot") + // This fake answers like an older daemon: its receipt carries no + // starter terminal, so creation takes exactly one snapshot to look + // for one before creating the starter (CloudWorkspaceCreationCoordinator). + #expect(fixture.provider.refreshes == 1, "a receipt without a starter costs one snapshot, not a re-sync per step") try #require(fixture.provider.createdTerminals.count == 1) #expect(fixture.provider.createdTerminals[0].remoteWorkspaceID == "ws_created") if response["ok"] as? Bool == true { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 06716b425ee0..b0fd628e2fc0 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2205,23 +2205,31 @@ final class TabManagerSessionSnapshotTests: XCTestCase { defer { catalog.unregister(machine: remote.machine) } let restored = TabManager() - restored.restoreSessionSnapshot(snapshot) - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == workspaceTitle }) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.panels.first { $0.value is TerminalPanel }?.key) - - // Until the machine's provider reports the terminal, the pane is a plain local shell. - XCTAssertEqual(catalog.projection(forPanel: restoredPanelId)?.resource.machine.isLocal, true) - catalog.upsert(SurfaceResource(id: remote, title: "root@\(machineId)", detail: "/root", lifecycle: .running, agent: nil, remoteWorkspace: nil, port: nil, url: nil), from: provider) - XCTAssertEqual(catalog.projection(forPanel: restoredPanelId)?.resource, remote, "the restored pane re-links to the remote terminal") - XCTAssertEqual(catalog.projection(forPanel: restoredPanelId)?.workspaceID, restoredWorkspace.id) - - // The projection round-trips through the next save with the live panel id. - let resaved = restored.sessionSnapshot(includeScrollback: false) - XCTAssertEqual( - resaved.workspaces.first { $0.customTitle == workspaceTitle }?.surfaceProjections, - [SurfaceProjectionRecord(panelID: restoredPanelId, resource: remote)] - ) - restored.closeWorkspace(restoredWorkspace, recordHistory: false) + // `SurfaceCatalog.shared` relinks a restored projection only into a workspace the + // app resolves as live (#13196), so the restored window must be registered. + try LiveWorkspaceFixture.withAppRegistration(of: restored) { + restored.restoreSessionSnapshot(snapshot) + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == workspaceTitle }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.panels.first { $0.value is TerminalPanel }?.key) + + // Until the machine's provider reports the terminal, the pane is a reserved Cloud + // pane (#12675): it has no live projection yet, but its persisted remote identity + // stays staged so the restore is never mistaken for a plain local shell. + XCTAssertNil(catalog.projection(forPanel: restoredPanelId)) + XCTAssertEqual(catalog.projectionIncludingPendingRestore(forPanel: restoredPanelId)?.resource, remote) + XCTAssertEqual(restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.ioMode, .manualMirror) + catalog.upsert(SurfaceResource(id: remote, title: "root@\(machineId)", detail: "/root", lifecycle: .running, agent: nil, remoteWorkspace: nil, port: nil, url: nil), from: provider) + XCTAssertEqual(catalog.projection(forPanel: restoredPanelId)?.resource, remote, "the restored pane re-links to the remote terminal") + XCTAssertEqual(catalog.projection(forPanel: restoredPanelId)?.workspaceID, restoredWorkspace.id) + + // The projection round-trips through the next save with the live panel id. + let resaved = restored.sessionSnapshot(includeScrollback: false) + XCTAssertEqual( + resaved.workspaces.first { $0.customTitle == workspaceTitle }?.surfaceProjections, + [SurfaceProjectionRecord(panelID: restoredPanelId, resource: remote)] + ) + restored.closeWorkspace(restoredWorkspace, recordHistory: false) + } } func testWorkspaceSnapshotWithoutSurfaceProjectionsDecodesAndRestoresLocalOnly() throws { diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index c22c24853d8d..16fb4e7c85bc 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -479,7 +479,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } - func testDefaultFreestyleCloudSplitRepairsRawSSHStartupCommand() throws { + func testDefaultFreestyleCloudSplitRoutesToCloudAndRepairsRawSSHStartupCommand() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId else { @@ -507,13 +507,29 @@ final class TabManagerChildExitCloseTests: XCTestCase { autoConnect: false ) - let splitPanel = try XCTUnwrap( - workspace.newTerminalSplit(from: remotePanelId, orientation: .horizontal, focus: false) + // The workspace's startup command is repaired from the raw `ssh` form to the + // default-freestyle `vm-pty-attach` attach for every local terminal it spawns. + let repairedCommand = try XCTUnwrap( + workspace.effectiveRemoteTerminalStartupCommand(from: workspace.remoteConfiguration) + ) + XCTAssertTrue(repairedCommand.contains("vm-pty-attach"), repairedCommand) + XCTAssertTrue(repairedCommand.contains("--default-freestyle-sshd"), repairedCommand) + XCTAssertFalse(repairedCommand.contains("ssh -p 22"), repairedCommand) + + // A split from the managed-Cloud SSH pane is Cloud-owned (fa5dc4cc10): it routes + // to the machine's provider and fails closed without one, never spawning a local + // shell next to the remote pane. + XCTAssertEqual(workspace.machineOwningSurface(remotePanelId), .cloud("71smiccrg35sw9pydt8k")) + let panelIdsBeforeSplit = Set(workspace.panels.keys) + let outcome = workspace.newTerminalSplitOutcome( + from: remotePanelId, orientation: .horizontal, focus: false ) - let splitCommand = try XCTUnwrap(splitPanel.surface.debugInitialCommand()) - XCTAssertTrue(splitCommand.contains("vm-pty-attach"), splitCommand) - XCTAssertTrue(splitCommand.contains("--default-freestyle-sshd"), splitCommand) - XCTAssertFalse(splitCommand.contains("ssh -p 22"), splitCommand) + XCTAssertFalse(outcome.isAccepted) + XCTAssertNil(outcome.panel) + XCTAssertEqual(Set(workspace.panels.keys), panelIdsBeforeSplit) + let failure = try XCTUnwrap(workspace.cloudPaneCreationFailureStore.failure) + XCTAssertEqual(failure.machine, .cloud("71smiccrg35sw9pydt8k")) + XCTAssertEqual(failure.sourcePanelID, remotePanelId) } func testDefaultFreestyleCloudReconnectRepairsRawSSHStartupCommand() throws { @@ -2164,11 +2180,29 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { appDelegate.notificationStore = originalNotificationStore } + // A workspace's LAST surface is not closed by the shortcut at all: with + // the close-on-last-surface preference enabled (its default) the close + // is escalated to the window-close path, which a window-less test + // TabManager cannot perform, so nothing closes and nothing is cleared. + // Give the workspace a second surface so the shortcut closes the + // surface itself, which is what this test is about. guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") + let paneId = workspace.bonsplitController.focusedPaneId, + let initialPanelId = workspace.focusedPanelId, + let initialTerminalPanel = workspace.terminalPanel(for: initialPanelId), + workspace.newTerminalSurface(inPane: paneId, focus: false) != nil else { + XCTFail("Expected workspace with two terminal surfaces") return } + workspace.focusPanel(initialPanelId) + // Close confirmation is orthogonal to notification clearing, and the + // ambient warn-before-closing default would otherwise decide whether + // the surface closes at all. + initialTerminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) + manager.confirmCloseHandler = { _, _, _ in + XCTFail("Close confirmation must not be required for this surface") + return false + } store.addNotification( tabId: workspace.id, @@ -2183,6 +2217,7 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { drainMainQueue() drainMainQueue() + XCTAssertNil(workspace.panels[initialPanelId]) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 4b41b30cfb23..f0f79b10b892 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1831,6 +1831,15 @@ final class TerminalOffscreenStartupTests: XCTestCase { #endif private func waitForMobileHostRoutesForTesting() async -> Bool { + let routes = MobileHostPublicStatusCache.snapshot() + if !routes.contains(where: { $0.id == "debug_loopback" }), + let route = try? CmxAttachRoute( + id: "debug_loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 58465), priority: 0 + ) { + MobileHostPublicStatusCache.update(routes: routes + [route]) + return true + } for _ in 0..<200 { let response = await TerminalController.shared.mobileHostHandleRPC( MobileHostRPCRequest( @@ -3044,7 +3053,7 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { } private func makeWindow() -> NSWindow { - let window = NSWindow( + let window = KeyStatusTestWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), styleMask: [.titled, .closable], backing: .buffered, @@ -3054,6 +3063,32 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return window } + /// A live portal-rendering authority for a standalone surface fixture. + /// + /// `setVisibleInUI` and `setActive` both fold their request through + /// `Workspace.portalRenderingEnabled(for:)`, which denies any workspace id + /// the app delegate cannot resolve to a *selected* tab. A surface built + /// with a made-up `tabId` is therefore never actually made visible or + /// active, so it never takes Ghostty focus and never schedules a + /// visibility-restore redraw: the fixture silently stops exercising the + /// behavior under test. Register a real selected workspace and build the + /// surface with its id so the fixture gets the authority the app grants + /// the selected tab. + /// + /// Returns `nil` only when no app delegate is installed, where the + /// authority already defaults to allowing the portal. + private func makeLivePortalWorkspace() -> (id: UUID, tearDown: @MainActor () -> Void)? { + guard let appDelegate = AppDelegate.shared else { return nil } + let manager = TabManager(autoWelcomeIfNeeded: false) + guard let workspace = manager.selectedWorkspace else { return nil } + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + return (workspace.id, { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + appDelegate.forgetRecoverableMainWindowRoute(windowId: windowId) + manager.finalizeAllWorkspacesForWindowClose() + }) + } + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { guard let event = NSEvent.mouseEvent( with: type, @@ -3289,6 +3324,8 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) + hostedView.setVisibleInUI(true) + hostedView.setActive(true) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -3300,7 +3337,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating the detach race") + // Runtime creation is asynchronous in the app host; wait for it instead of + // assuming the fixed run-loop spin above finished it. + XCTAssertTrue( + waitUntil(timeout: 5.0) { surface.surface != nil }, + "Expected runtime surface before simulating the detach race" + ) surface.releaseSurfaceForTesting() XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup") @@ -3332,8 +3374,11 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } + let livePortalWorkspace = makeLivePortalWorkspace() + defer { livePortalWorkspace?.tearDown() } + let surface = TerminalSurface( - tabId: UUID(), + tabId: livePortalWorkspace?.id ?? UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, workingDirectory: nil @@ -3342,6 +3387,8 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) + hostedView.setVisibleInUI(true) + hostedView.setActive(true) let otherResponder = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) contentView.addSubview(otherResponder) @@ -3359,7 +3406,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTAssertTrue(window.makeFirstResponder(surfaceView)) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue(surface.debugDesiredFocusState(), "Focused terminal should start with desired Ghostty focus") + // First-responder focus reaches the surface through the deferred main-actor + // scheduler; wait for it rather than sampling after a fixed spin. + XCTAssertTrue( + waitUntil(timeout: 2.0) { surface.debugDesiredFocusState() }, + "Focused terminal should start with desired Ghostty focus" + ) surface.releaseSurfaceForTesting() XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup") @@ -3423,6 +3475,7 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) + hostedView.setVisibleInUI(true) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -3434,7 +3487,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating close lifecycle teardown") + // Runtime creation is asynchronous in the app host; wait for it instead of + // assuming the fixed run-loop spin above finished it. + XCTAssertTrue( + waitUntil(timeout: 5.0) { surface.surface != nil }, + "Expected runtime surface before simulating close lifecycle teardown" + ) surface.beginPortalCloseLifecycle(reason: "test.close") surface.teardownSurface() @@ -3489,7 +3547,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before sending repeat key input") + // Runtime creation is asynchronous in the app host; wait for it instead of + // assuming the fixed run-loop spin above finished it. + XCTAssertTrue( + waitUntil(timeout: 5.0) { surface.surface != nil }, + "Expected runtime surface before sending repeat key input" + ) XCTAssertTrue(window.makeFirstResponder(surfaceView)) let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler @@ -3576,7 +3639,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before sending repeat IME input") + // Runtime creation is asynchronous in the app host; wait for it instead of + // assuming the fixed run-loop spin above finished it. + XCTAssertTrue( + waitUntil(timeout: 5.0) { surface.surface != nil }, + "Expected runtime surface before sending repeat IME input" + ) XCTAssertTrue(window.makeFirstResponder(surfaceView)) let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler @@ -3645,8 +3713,11 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } + let livePortalWorkspace = makeLivePortalWorkspace() + defer { livePortalWorkspace?.tearDown() } + let surface = TerminalSurface( - tabId: UUID(), + tabId: livePortalWorkspace?.id ?? UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, workingDirectory: nil @@ -3655,6 +3726,7 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) + hostedView.setVisibleInUI(true) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -3674,6 +3746,9 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { surface.resetDebugForceRefreshCount() hostedView.setVisibleInUI(true) drainMainQueue() + // The visibility-restore redraw is scheduled through a main-actor task, which + // `drainMainQueue` (GCD) does not drain; wait for it before counting. + _ = waitUntil(timeout: 2.0) { surface.debugForceRefreshCount() >= 1 } XCTAssertEqual( surface.debugForceRefreshCount(), @@ -3695,8 +3770,11 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } + let livePortalWorkspace = makeLivePortalWorkspace() + defer { livePortalWorkspace?.tearDown() } + let surface = TerminalSurface( - tabId: UUID(), + tabId: livePortalWorkspace?.id ?? UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, workingDirectory: nil @@ -3721,16 +3799,20 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before measuring focus redraws") + // Runtime creation is asynchronous in the app host; wait for it instead of + // assuming the fixed run-loop spin above finished it. + XCTAssertTrue( + waitUntil(timeout: 5.0) { surface.surface != nil }, + "Expected runtime surface before measuring focus redraws" + ) XCTAssertTrue(window.makeFirstResponder(surfaceView)) XCTAssertTrue(window.makeFirstResponder(otherResponder)) surface.resetDebugForceRefreshCount() XCTAssertTrue(window.makeFirstResponder(surfaceView)) - XCTAssertGreaterThan( - surface.debugForceRefreshCount(), - 0, + XCTAssertTrue( + waitUntil(timeout: 2.0) { surface.debugForceRefreshCount() > 0 }, "Clicking back into the terminal should redraw immediately so the cursor reflects focused input" ) #else @@ -4031,7 +4113,10 @@ final class WindowTerminalHostViewTests: XCTestCase { } func testHostViewStopsSidebarPassThroughJustInsideTerminalContent() { - let terminalSideOverlapWidth: CGFloat = 2 + // The split-divider hit band reaches `dividerHitExpansion` past the divider edge + // (667cc431d9). With the `.thin` (1 pt) divider below, the last pass-through point + // on the terminal side is one point inside that band. + let terminalSideOverlapWidth: CGFloat = 0.5 + PortalSplitDividerRegion.dividerHitExpansion - 1 let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), styleMask: [.titled, .closable], @@ -4109,6 +4194,21 @@ final class GhosttySurfaceOverlayTests: XCTestCase { private final class ScrollbarPostingSurfaceView: GhosttyNSView { var nextScrollbar: GhosttyScrollbar? + // Wheel sync requires an authoritative scrollbar response (bbc3edfae4); a + // passive packet posted after the wheel event no longer moves the viewport. + override func readAuthoritativeScrollbar( + _ result: UnsafeMutablePointer + ) -> Bool { + guard let nextScrollbar else { return false } + result.pointee = ghostty_surface_scrollbar_s( + total: nextScrollbar.total, + offset: nextScrollbar.offset, + len: nextScrollbar.len, + row_space_revision: 1 + ) + return true + } + override func scrollWheel(with event: NSEvent) { super.scrollWheel(with: event) guard let nextScrollbar else { return } @@ -4146,10 +4246,6 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } } - private final class KeyStatusTestWindow: NSWindow { - override var isKeyWindow: Bool { true } - } - private func makeScrollbar(total: UInt64, offset: UInt64, len: UInt64) -> GhosttyScrollbar { GhosttyScrollbar( c: ghostty_action_scrollbar_s( @@ -4922,24 +5018,35 @@ final class GhosttySurfaceOverlayTests: XCTestCase { scrollView.scrollerStyle = .legacy scrollView.layoutSubtreeIfNeeded() - let legacyContentWidth = scrollView.contentSize.width XCTAssertEqual(scrollView.scrollerStyle, .legacy) assertPendingSurfaceWidth( initialSurfaceSize.width, "Changing the scroll view style alone should leave the terminal grid unchanged until the scroller-style observer runs" ) + // Scroller presence is a function of the scroller style (#12918): under + // the overlay style a surface without scrollback carries no scroller, so + // the scroll view reserves nothing until the observer re-evaluates + // presence for the legacy style. Expect the gutter AppKit reserves for a + // legacy scroller rather than snapshotting the content width before the + // product has applied that choice. NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) XCTAssertTrue( waitUntil(description: "legacy terminal scrollbar geometry") { scrollView.scrollerStyle == .legacy && + scrollView.hasVerticalScroller && + scrollView.contentSize.width < initialContentWidth && hostedView.debugPendingSurfaceSize().map { - abs($0.width - legacyContentWidth) <= 0.5 + abs($0.width - scrollView.contentSize.width) <= 0.5 } == true } ) let preservedLegacyContentWidth = scrollView.contentSize.width + let legacyScrollerWidth = NSScroller.scrollerWidth( + for: scrollView.verticalScroller?.controlSize ?? .regular, + scrollerStyle: .legacy + ) XCTAssertEqual(scrollView.scrollerStyle, .legacy) XCTAssertGreaterThanOrEqual( initialContentWidth, @@ -4948,7 +5055,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) XCTAssertEqual( preservedLegacyContentWidth, - legacyContentWidth, + initialContentWidth - legacyScrollerWidth, accuracy: 0.5, "Preferred scroller style changes should preserve the system's legacy scrollbar choice" ) @@ -5394,7 +5501,11 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let surface = makeTrackedTerminalSurface() let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + // The overlay mounts through a deferred main-actor task; wait for it like the + // sibling mount tests instead of assuming a fixed run-loop spin drained it. + waitUntil(description: "search overlay to mount") { + hostedView.debugHasSearchOverlay() + } XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) @@ -5428,7 +5539,11 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let surface = makeTrackedTerminalSurface() let hostedView = surface.hostedView hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + // The overlay mounts through a deferred main-actor task; wait for it like the + // sibling mount tests instead of assuming a fixed run-loop spin drained it. + waitUntil(description: "search overlay to mount") { + hostedView.debugHasSearchOverlay() + } XCTAssertTrue(hostedView.debugHasSearchOverlay()) portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) @@ -6624,7 +6739,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { accessMode: .allowAll ) - XCTAssertFalse(transport.pathAcceptsConnections(path)) + // Owning the lock is definitive (959f38a4c3): a refused socket inode left by a + // dead listener is replaced, so the restarted listener accepts connections. + XCTAssertTrue(transport.pathAcceptsConnections(path)) } @MainActor diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 1ac57f333851..10e30d544587 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -1174,7 +1174,8 @@ final class TerminalControllerSocketSecurityTests { "mobile.terminal.viewport", "terminal.viewport", "mobile.panel.artifact.stat", - "mobile.panel.artifact.fetch", + // fetch is mobile-only (authenticated execution context); the + // local socket neither serves nor advertises it. "mobile.panel.artifact.thumbnail", "mobile.events.subscribe", "mobile.events.unsubscribe", diff --git a/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift b/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift index e85d906e7118..855286e2f44b 100644 --- a/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift +++ b/cmuxTests/TerminalFontZoomSessionPersistenceTests.swift @@ -1005,12 +1005,14 @@ struct TerminalFontZoomSessionPersistenceTests { let firstMirrorPanel = try #require(mirror.panel(forPane: 11)) let secondMirrorPanel = try #require(mirror.panel(forPane: 22)) - firstMirrorPanel.surface.recordCurrentFontSizeLineage( - TerminalFontSizeLineage(basePoints: 6, isExplicitOverride: true) - ) - secondMirrorPanel.surface.recordCurrentFontSizeLineage( - TerminalFontSizeLineage(basePoints: 4, isExplicitOverride: true) - ) + // Manual-I/O panes already have a runtime: adjust the live font, not + // only its durable cache, which the next live snapshot replaces. + // Mirror panes inherit the outer 8pt source, so these deltas land them + // at 6pt and 4pt before the workspace-wide -1. + #expect(firstMirrorPanel.surface.fontSizeLineageSnapshot()?.basePoints == 8) + #expect(secondMirrorPanel.surface.fontSizeLineageSnapshot()?.basePoints == 8) + #expect(firstMirrorPanel.surface.adjustFontSize(byRuntimePoints: -2)) + #expect(secondMirrorPanel.surface.adjustFontSize(byRuntimePoints: -4)) #expect(workspace.adjustTerminalFontSizes(byRuntimePoints: -1) == 3) #expect(outerPanel.surface.fontSizeLineageSnapshot()?.basePoints == 7) diff --git a/cmuxTests/TerminalLocalImageTransferFileLifetimeTests.swift b/cmuxTests/TerminalLocalImageTransferFileLifetimeTests.swift index 8e044155ffd8..f69ff5f010c9 100644 --- a/cmuxTests/TerminalLocalImageTransferFileLifetimeTests.swift +++ b/cmuxTests/TerminalLocalImageTransferFileLifetimeTests.swift @@ -13,7 +13,7 @@ import Testing @Suite(.serialized) struct TerminalLocalImageTransferFileLifetimeTests { private static let onePixelPNGBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lS2cWQAAAABJRU5ErkJggg==" + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" private struct HostedTerminal { let surface: TerminalSurface diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 370aaf2d069a..b439f0e63181 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -168,7 +168,9 @@ struct BrowserInsecureHTTPSettingsTests { checkEqual(prepared.httpMethod, "POST") checkEqual(prepared.httpBody, Data("token=abc123".utf8)) checkEqual(prepared.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded") - checkEqual(prepared.cachePolicy, .useProtocolCachePolicy) + // #13003: the prepared request keeps the caller's cache policy so refreshing a + // failed navigation replays the original request semantics. + checkEqual(prepared.cachePolicy, .reloadIgnoringLocalAndRemoteCacheData) } @Test diff --git a/cmuxTests/VMDefaultCloudCommandTests.swift b/cmuxTests/VMDefaultCloudCommandTests.swift index 950260b3b387..a3fd6ef6d593 100644 --- a/cmuxTests/VMDefaultCloudCommandTests.swift +++ b/cmuxTests/VMDefaultCloudCommandTests.swift @@ -854,7 +854,10 @@ extension CLINotifyProcessIntegrationRegressionTests { let result = try XCTUnwrap(resultBox.load()) XCTAssertFalse(result.timedOut, result.stdout + result.stderr) XCTAssertEqual(result.status, 0, result.stdout + result.stderr) - XCTAssertTrue(result.stdout.contains("CMUX_DELAYED_RELAY_OK"), result.stdout + result.stderr) + XCTAssertTrue( + (result.stdout + result.stderr).contains("CMUX_DELAYED_RELAY_OK"), + result.stdout + result.stderr + ) XCTAssertFalse(result.stderr.contains("credential prompt timed out"), result.stderr) XCTAssertFalse(result.stderr.lowercased().contains("password:"), result.stderr) XCTAssertFalse((result.stdout + result.stderr).contains("lease-token")) diff --git a/cmuxTests/VMSSHCommandTests.swift b/cmuxTests/VMSSHCommandTests.swift index c20b4b8e4204..cbbe9b9ed28a 100644 --- a/cmuxTests/VMSSHCommandTests.swift +++ b/cmuxTests/VMSSHCommandTests.swift @@ -104,7 +104,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } XCTAssertEqual( requests.compactMap { $0["method"] as? String }, - ["vm.ssh_info", "workspace.create", "workspace.rename", "workspace.remote.configure", "workspace.select"] + ["vm.ssh_info", "workspace.create", "workspace.remote.configure", "workspace.select"] ) let createRequest = try XCTUnwrap( diff --git a/cmuxTests/WindowOverlayChromeTests.swift b/cmuxTests/WindowOverlayChromeTests.swift index 52047c85dee3..1042ebad2a69 100644 --- a/cmuxTests/WindowOverlayChromeTests.swift +++ b/cmuxTests/WindowOverlayChromeTests.swift @@ -92,10 +92,17 @@ struct WindowOverlayChromeTests { let content = try #require(window.contentView) let browserAnchor = try #require(find("overlay.browser", in: content)) let terminalAnchor = try #require(find("overlay.terminal", in: content)) + let glassEffect = WindowGlassEffect() if useGlass { - WindowGlassEffect().apply(to: window) + glassEffect.apply(to: window) + } + let windowRoot = try #require(window.contentView) + if useGlass && glassEffect.isAvailable { + #expect(windowRoot !== content) + #expect(glassEffect.originalContentView(for: window) === content) + } else { + #expect(windowRoot === content) } - let windowRoot = window.contentView let browser = WindowBrowserPortal(window: window) let terminal = WindowTerminalPortal(window: window) defer { browser.tearDown(); terminal.tearDown() } @@ -110,11 +117,7 @@ struct WindowOverlayChromeTests { browser.synchronizeWebViewForAnchor(browserAnchor) terminal.synchronizeHostedViewForAnchor(terminalAnchor) let root = try #require(window.contentView) - if useGlass { - #expect(root !== windowRoot) - } else { - #expect(root === windowRoot) - } + #expect(root === windowRoot, "Portals must preserve the root installed before they bind.") #expect(webView.window === window) let browserFrame = browserAnchor.convert(browserAnchor.bounds, to: nil) let browserPoint = NSPoint(x: browserFrame.midX, y: browserFrame.midY) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index fb69d794d932..3fe0b030d2bf 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -16,6 +16,7 @@ import Bonsplit @Suite(.serialized) final class WorkspaceContentViewVisibilityTests { private final class MinimalModeBodyProbeCounts { + var isMeasuringInvalidations = false var contentViewBody = 0 var workspaceContentBody = 0 var verticalTabsSidebarBody = 0 @@ -220,9 +221,9 @@ final class WorkspaceContentViewVisibilityTests { forKey: WorkspacePresentationModeSettings.modeKey ) - let tabManager = TabManager() - for _ in 0..<6 { - tabManager.addWorkspace(autoWelcomeIfNeeded: false) + let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) + for _ in 0..<7 { + tabManager.addWorkspace(initialSurface: .cloudVMLoading, select: tabManager.tabs.isEmpty, autoWelcomeIfNeeded: false) } let notificationStore = TerminalNotificationStore.shared let counts = MinimalModeBodyProbeCounts() @@ -236,6 +237,7 @@ final class WorkspaceContentViewVisibilityTests { .environment( \.minimalModeInvalidationProbe, MinimalModeInvalidationProbe( + shouldTraceBodyChanges: { counts.isMeasuringInvalidations }, contentViewBody: { counts.contentViewBody += 1 }, workspaceContentBody: { counts.workspaceContentBody += 1 }, verticalTabsSidebarBody: { counts.verticalTabsSidebarBody += 1 } @@ -249,25 +251,28 @@ final class WorkspaceContentViewVisibilityTests { backing: .buffered, defer: false ) + window.isReleasedWhenClosed = false window.contentView = MainWindowHostingView(rootView: root) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() defer { window.contentView = nil + tabManager.finalizeAllWorkspacesForWindowClose() window.close() } - await Self.drainMainRunLoop(for: window) #expect(counts.contentViewBody > 0) #expect(counts.workspaceContentBody > 0) #expect(counts.verticalTabsSidebarBody > 0) - counts.reset() + counts.isMeasuringInvalidations = true + defer { counts.isMeasuringInvalidations = false } defaults.set( WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey ) await Self.drainMainRunLoop(for: window) + counts.isMeasuringInvalidations = false #expect( counts.contentViewBody == 0, diff --git a/cmuxTests/WorkspaceCreateInitialInputTests.swift b/cmuxTests/WorkspaceCreateInitialInputTests.swift index 07fefb46cb40..6956ea5160aa 100644 --- a/cmuxTests/WorkspaceCreateInitialInputTests.swift +++ b/cmuxTests/WorkspaceCreateInitialInputTests.swift @@ -193,7 +193,7 @@ extension WorkspaceCreateWorkingDirectoryTests { #expect(panel.surface.debugWaitAfterCommand() == false) } - @Test func explicitInitialInputKeepsCloudProjectedSplitLocal() throws { + @Test func explicitInitialInputRejectsCloudProjectedSplit() throws { let catalog = SurfaceCatalog.shared let machine = SurfaceMachineID.cloud("test-cloud-\(UUID().uuidString)") let provider = CloudRoutingProvider(machine: machine) @@ -238,15 +238,11 @@ extension WorkspaceCreateWorkingDirectoryTests { focus: false, initialInput: input ) - switch outcome { - case .created(let panel): - #expect(panel.surface.debugInitialInputForTesting() == input) - #expect(provider.createTerminalCallCount == 0) - case .routedToRemote: - Issue.record("a split with explicit initial input must stay local") - case .failed: - Issue.record("a split with explicit initial input should create a local terminal") + guard case .failed = outcome else { + Issue.record("Cloud-owned terminal creation must reject unsupported local startup input") + return } + #expect(provider.createTerminalCallCount == 0) } private static func v2SocketResponse( diff --git a/cmuxTests/WorkspaceDragSplitFocusTests.swift b/cmuxTests/WorkspaceDragSplitFocusTests.swift index ef9d5da085c2..d5fe35c29715 100644 --- a/cmuxTests/WorkspaceDragSplitFocusTests.swift +++ b/cmuxTests/WorkspaceDragSplitFocusTests.swift @@ -110,7 +110,7 @@ struct WorkspaceDragSplitFocusSwiftTests { } @Test - func nonFocusSplitPreservesCursorAndHibernationDuringSuspendedPresentation() throws { + func nonFocusSplitPreservesCursorAndHibernationDuringSuspendedPresentation() async throws { let originalAppDelegate = AppDelegate.shared let owner = TerminalPortalTestWorkspace() @@ -161,6 +161,7 @@ struct WorkspaceDragSplitFocusSwiftTests { focusIntent: .preserveCurrent ) ) + await AppKitTestEventPump().drain() #expect( fixture.workspace.bonsplitController.tabs(inPane: newPane) diff --git a/cmuxTests/WorkspaceTerminalFocusRecoveryTests.swift b/cmuxTests/WorkspaceTerminalFocusRecoveryTests.swift index 05763bcb4744..fa26c2cc931a 100644 --- a/cmuxTests/WorkspaceTerminalFocusRecoveryTests.swift +++ b/cmuxTests/WorkspaceTerminalFocusRecoveryTests.swift @@ -53,6 +53,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { panel.hostedView.setActive(true) window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() panel.hostedView.layoutSubtreeIfNeeded() @@ -136,6 +137,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { panel.hostedView.setActive(true) window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() panel.hostedView.layoutSubtreeIfNeeded() @@ -219,6 +221,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { panel.hostedView.setActive(true) window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() panel.hostedView.layoutSubtreeIfNeeded() @@ -300,6 +303,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { panel.hostedView.setActive(true) window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() panel.hostedView.layoutSubtreeIfNeeded() @@ -367,6 +371,8 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { } let workspace = try #require(manager.selectedWorkspace, "Expected initial workspace") + manager.selectWorkspace(workspace) + workspace.setPortalRenderingEnabled(true, reason: "focus-recovery-test") let panelId = try #require(workspace.focusedPanelId, "Expected initial focused panel") let panel = try #require(workspace.terminalPanel(for: panelId), "Expected initial terminal panel") let contentView = try #require(window.contentView, "Expected content view") @@ -385,6 +391,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { panel.hostedView.preparePanelFocusIntentForActivation(.surface) window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() panel.hostedView.layoutSubtreeIfNeeded() @@ -541,7 +548,7 @@ struct WorkspaceTerminalFocusRecoverySwiftTests { #endif private func makeWindow() -> NSWindow { - NSWindow( + KeyStatusTestWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), styleMask: [.titled, .closable], backing: .buffered, diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 9871fac06f24..af4edf9e6b38 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1,4 +1,5 @@ import CmuxCore +import Darwin import AppKit import CmuxFoundation import CmuxTerminalCore @@ -506,6 +507,22 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { } func testRightSidebarModeSwitchesHavePrivateControlDigitDefaults() { + let defaults = UserDefaults.standard + let feedKey = RightSidebarBetaFeatureSettings.feedEnabledKey + let dockKey = RightSidebarBetaFeatureSettings.dockEnabledKey + let previousFeed = defaults.object(forKey: feedKey) + let previousDock = defaults.object(forKey: dockKey) + defer { + if let previousFeed { defaults.set(previousFeed, forKey: feedKey) } + else { defaults.removeObject(forKey: feedKey) } + if let previousDock { defaults.set(previousDock, forKey: dockKey) } + else { defaults.removeObject(forKey: dockKey) } + } + // Positional defaults are intentionally derived from the visible tab + // bar. Make the beta-only tabs visible so this test exercises the + // complete five-action mapping rather than the disabled-tab fallback. + defaults.set(true, forKey: feedKey) + defaults.set(true, forKey: dockKey) let modeSwitchActions: [(KeyboardShortcutSettings.Action, String)] = [ (.switchRightSidebarToFiles, "1"), (.switchRightSidebarToFind, "2"), @@ -3668,10 +3685,11 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { injectedConfig = config } - override func inheritedTerminalConfigForNewWorkspace( + override func inheritedTerminalFontSizeLineageForNewWorkspace( workspace: Workspace? - ) -> CmuxSurfaceConfigTemplate? { - injectedConfig ?? super.inheritedTerminalConfigForNewWorkspace(workspace: workspace) + ) -> TerminalFontSizeLineage? { + injectedConfig?.fontSizeLineage + ?? super.inheritedTerminalFontSizeLineageForNewWorkspace(workspace: workspace) } override func makeWorkspaceForCreation( @@ -3715,6 +3733,7 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { func testAddWorkspacePassesSanitizedInheritedConfigTemplate() { let manager = UnsafeConfigSnapshotTabManager() manager.installInjectedConfig(fontSize: 19) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } _ = manager.addWorkspace() @@ -4868,7 +4887,7 @@ final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { @MainActor final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { private func makeWindow() -> NSWindow { - NSWindow( + KeyStatusTestWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), styleMask: [.titled, .closable], backing: .buffered, @@ -4909,7 +4928,9 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { } func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { - let workspace = Workspace() + let fixture = TerminalPortalTestWorkspace() + defer { fixture.tearDown() } + let workspace = fixture.workspace guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -4942,123 +4963,159 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { ) } - func testTerminalFirstResponderFeedbackPreservesActiveFocusTransaction() { - let originalAppDelegate = AppDelegate.shared - let appDelegate = originalAppDelegate ?? AppDelegate() - let manager = TabManager(autoWelcomeIfNeeded: false) - let originalTabManager = appDelegate.tabManager - let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) - AppDelegate.shared = appDelegate - appDelegate.tabManager = manager - defer { - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - appDelegate.tabManager = originalTabManager - AppDelegate.shared = originalAppDelegate - } - - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - let leftPaneId = workspace.paneId(forPanelId: leftPanel.id), - let leftTabId = workspace.surfaceIdFromPanelId(leftPanel.id) else { - XCTFail("Expected split terminal panels") - return - } + func testTerminalFirstResponderFeedbackPreservesActiveFocusTransaction() async { + await AppContextSerialGate.withExclusiveAppContext { + let originalAppDelegate = AppDelegate.shared + let appDelegate = originalAppDelegate ?? AppDelegate() + let manager = TabManager(autoWelcomeIfNeeded: false) + let originalTabManager = appDelegate.tabManager + let window = makeWindow() + defer { window.orderOut(nil) } + let windowId = UUID() + appDelegate.registerMainWindow( + window, windowId: windowId, tabManager: manager, + sidebarState: SidebarState(), sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + AppDelegate.shared = appDelegate + appDelegate.tabManager = manager + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + appDelegate.forgetRecoverableMainWindowRoute(windowId: windowId) + manager.finalizeAllWorkspacesForWindowClose() + appDelegate.tabManager = originalTabManager + AppDelegate.shared = originalAppDelegate + } - let window = makeWindow() - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanel.id), + let leftTabId = workspace.surfaceIdFromPanelId(leftPanel.id) else { + XCTFail("Expected split terminal panels") + return + } - leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) - rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) - contentView.addSubview(leftPanel.hostedView) - contentView.addSubview(rightPanel.hostedView) - leftPanel.hostedView.setVisibleInUI(true) - rightPanel.hostedView.setVisibleInUI(true) + manager.selectWorkspace(workspace) + workspace.setPortalRenderingEnabled(true, reason: "focus-feedback-test") + appDelegate.setActiveMainWindow(window) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - leftPanel.hostedView.layoutSubtreeIfNeeded() - rightPanel.hostedView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - FocusSurfaceBroadcaster.shared.flush() + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } - var firstResponderFeedbackCount = 0 - leftPanel.hostedView.setFocusHandler { - firstResponderFeedbackCount += 1 - workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) - } + leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) + rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) + contentView.addSubview(leftPanel.hostedView) + contentView.addSubview(rightPanel.hostedView) + leftPanel.hostedView.setVisibleInUI(true) + rightPanel.hostedView.setVisibleInUI(true) + leftPanel.hostedView.setActive(true) + rightPanel.hostedView.setActive(true) + + window.makeKeyAndOrderFront(nil) + appDelegate.setActiveMainWindow(window) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + leftPanel.hostedView.layoutSubtreeIfNeeded() + rightPanel.hostedView.layoutSubtreeIfNeeded() + await AppKitTestEventPump().startSurface(leftPanel.surface) + await AppKitTestEventPump().startSurface(rightPanel.surface) + leftPanel.hostedView.reconcileGeometryNow() + rightPanel.hostedView.reconcileGeometryNow() + appDelegate.noteMainPanelKeyboardFocusIntent( + workspaceId: workspace.id, panelId: leftPanel.id, in: window + ) - var observedTransactions: [UUID] = [] - let token = NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: nil - ) { notification in - guard notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID == workspace.id, - notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID == leftPanel.id, - let transactionId = notification.userInfo?[GhosttyNotificationKey.focusTransactionId] as? UUID else { - return + var firstResponderFeedbackCount = 0 + leftPanel.hostedView.setFocusHandler { + firstResponderFeedbackCount += 1 + workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) } - observedTransactions.append(transactionId) - } - defer { NotificationCenter.default.removeObserver(token) } - var sawFirstResponderNotification = false - var observedFirstResponderTransactions: [UUID] = [] - let firstResponderToken = NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: nil - ) { notification in - guard notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID == workspace.id, - notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID == leftPanel.id else { - return + var observedTransactions: [UUID] = [] + let token = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: nil + ) { notification in + guard notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID == workspace.id, + notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID == leftPanel.id, + let transactionId = notification.userInfo?[GhosttyNotificationKey.focusTransactionId] as? UUID else { + return + } + observedTransactions.append(transactionId) } - sawFirstResponderNotification = true - if let transactionId = notification.userInfo?[GhosttyNotificationKey.focusTransactionId] as? UUID { - observedFirstResponderTransactions.append(transactionId) + defer { NotificationCenter.default.removeObserver(token) } + + var sawFirstResponderNotification = false + var observedFirstResponderTransactions: [UUID] = [] + let firstResponderToken = NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: nil + ) { notification in + guard notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID == workspace.id, + notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID == leftPanel.id else { + return + } + sawFirstResponderNotification = true + if let transactionId = notification.userInfo?[GhosttyNotificationKey.focusTransactionId] as? UUID { + observedFirstResponderTransactions.append(transactionId) + } } - } - defer { NotificationCenter.default.removeObserver(firstResponderToken) } + defer { NotificationCenter.default.removeObserver(firstResponderToken) } + + let transactionId = UUID() + window.makeFirstResponder(nil) + workspace.applyTabSelection( + tabId: leftTabId, + inPane: leftPaneId, + focusTransactionId: transactionId + ) + FocusSurfaceBroadcaster.shared.flush() - let transactionId = UUID() - window.makeFirstResponder(nil) - workspace.applyTabSelection( - tabId: leftTabId, - inPane: leftPaneId, - focusTransactionId: transactionId - ) - FocusSurfaceBroadcaster.shared.flush() + // Selection applies focus through the AppKit event queue. Drain the + // queue before inspecting callbacks so this assertion observes the + // same first-responder transition as the hosted app. + let firstResponderFeedbackObserved = await AppKitTestEventPump().waitUntil( + timeout: .seconds(5) + ) { + firstResponderFeedbackCount > 0 + } + XCTAssertTrue( + firstResponderFeedbackObserved, + "Expected AppKit first-responder focus to feed back through workspace.focusPanel" + ) - XCTAssertGreaterThan( - firstResponderFeedbackCount, - 0, - "Expected AppKit first-responder focus to feed back through workspace.focusPanel" - ) - XCTAssertTrue( - sawFirstResponderNotification, - "Expected the terminal first-responder notification to be posted for the focused panel" - ) - XCTAssertEqual( - observedFirstResponderTransactions.last, - transactionId, - "Terminal first-responder notifications should carry the active focus transaction" - ) - XCTAssertEqual( - observedTransactions.last, - transactionId, - "Terminal first-responder feedback should stay in the active focus transaction instead of starting a new circuit" - ) + XCTAssertGreaterThan( + firstResponderFeedbackCount, + 0, + "Expected AppKit first-responder focus to feed back through workspace.focusPanel" + ) + XCTAssertTrue( + sawFirstResponderNotification, + "Expected the terminal first-responder notification to be posted for the focused panel" + ) + XCTAssertEqual( + observedFirstResponderTransactions.last, + transactionId, + "Terminal first-responder notifications should carry the active focus transaction" + ) + XCTAssertEqual( + observedTransactions.last, + transactionId, + "Terminal first-responder feedback should stay in the active focus transaction instead of starting a new circuit" + ) + } } func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() { - let workspace = Workspace() + let fixture = TerminalPortalTestWorkspace() + defer { fixture.tearDown() } + let workspace = fixture.workspace guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -5066,6 +5123,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { return } let window = makeWindow() + fixture.bind(to: window) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") @@ -5141,7 +5199,9 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { func testClearSuppressReparentFocusReassertsGhosttyFocusForCurrentFirstResponder() throws { #if DEBUG - let workspace = Workspace() + let fixture = TerminalPortalTestWorkspace() + defer { fixture.tearDown() } + let workspace = fixture.workspace guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -5152,6 +5212,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { XCTAssertEqual(workspace.focusedPanelId, leftPanel.id) let window = makeWindow() + fixture.bind(to: window) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") @@ -6545,7 +6606,15 @@ final class WorkspacePanelGitBranchTests: XCTestCase { func testForkAgentWorkspaceLaunchInRemoteWorkspacePreservesRemoteContext() throws { let workspace = Workspace() - let agentSocketPath = "/tmp/cmux-fork-agent.sock" + // `WorkspaceRemoteConfiguration.resolvedAgentSocketPath` only propagates an + // agent socket that exists on disk, so bind a real one for the fork. + let agentSocketPath = SSHStartupManualReconnectTests.makeSocketPath("fork-agent") + let socketFD = try SSHStartupManualReconnectTests.bindUnixSocket(at: agentSocketPath) + defer { + Darwin.close(socketFD) + unlink(agentSocketPath) + workspace.teardownAllPanels() + } workspace.configureRemoteConnection( WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -6589,7 +6658,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(launch.terminalWorkingDirectory) XCTAssertEqual( launch.initialTerminalCommand, - "ssh -p 2222 -i /Users/example/.ssh/cmux -o ServerAliveInterval=30 -o ForwardAgent=yes -tt cmux-macmini" + "/usr/bin/ssh -p 2222 -i /Users/example/.ssh/cmux -o ServerAliveInterval=30 -o ForwardAgent=yes -tt cmux-macmini" ) XCTAssertEqual(launch.initialTerminalInput, snapshot.forkCommand.map { $0 + "\n" }) XCTAssertEqual(launch.initialTerminalEnvironment["SSH_AUTH_SOCK"], agentSocketPath) @@ -6606,6 +6675,21 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } func testForkAgentWorkspaceLaunchFromPersistentSSHPTYDoesNotReuseParentRelayOrDaemonSlot() throws { + // The forked configuration only mints a fresh relay namespace when the + // control listener can name the socket the new session will reconnect + // through (`SessionRemoteWorkspaceSnapshot.workspaceConfiguration` + // requires a non-nil `localSocketPath`). That path comes from the + // process-wide `TerminalController.shared`, so reserve one here instead + // of inheriting whatever an earlier test in this app host left behind. + TerminalController.shared.stop(cleanupDiscoveryState: true) + let reservedSocket = TerminalController.shared.reserveStartupSocketPath( + "/tmp/cmux-fork-persistent-restore-\(UUID().uuidString).sock" + ) + defer { + TerminalController.shared.stop(cleanupDiscoveryState: true) + try? FileManager.default.removeItem(atPath: reservedSocket) + try? FileManager.default.removeItem(atPath: reservedSocket + ".lock") + } let workspace = Workspace() workspace.configureRemoteConnection( WorkspaceRemoteConfiguration( @@ -6658,6 +6742,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNotEqual(relayID, "relay-fork-persistent") XCTAssertNotEqual(relayToken, String(repeating: "c", count: 64)) XCTAssertNotEqual(localSocketPath, "/tmp/cmux-fork-persistent.sock") + XCTAssertEqual(localSocketPath, reservedSocket) XCTAssertNil(launch.remoteConfiguration?.persistentDaemonSlot) let startupCommand = try XCTUnwrap(launch.remoteConfiguration?.terminalStartupCommand) XCTAssertTrue(startupCommand.contains("terminal_session_launching"), startupCommand) @@ -6707,7 +6792,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(launch.workingDirectory, "/Users/cmux/fallback repo") XCTAssertNil(launch.terminalWorkingDirectory) - XCTAssertEqual(launch.initialTerminalCommand, "ssh -tt cmux-macmini") + XCTAssertEqual(launch.initialTerminalCommand, "/usr/bin/ssh -tt cmux-macmini") XCTAssertEqual( launch.initialTerminalInput, "cd -- '/Users/cmux/fallback repo' 2>/dev/null || [ ! -d '/Users/cmux/fallback repo' ] && '/Users/example/.bun/bin/codex' 'fork' '019dad34-d218-7943-b81a-eddac5c87951'\n" @@ -6985,10 +7070,11 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } var publishCount = 0 - let cancellable = workspace.objectWillChange.sink { _ in + let cancellable = workspace.sidebarObservationPublisher.sink { publishCount += 1 } defer { cancellable.cancel() } + publishCount = 0 workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) let baselinePublishCount = publishCount @@ -6996,7 +7082,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertGreaterThan( baselinePublishCount, 0, - "Expected the first focused branch update to publish workspace changes" + "Expected the first focused branch update to publish sidebar changes" ) workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) @@ -7004,7 +7090,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual( publishCount, baselinePublishCount, - "Expected identical focused branch refreshes to avoid extra workspace publishes" + "Expected identical focused branch refreshes to avoid extra sidebar publishes" ) } @@ -7018,10 +7104,11 @@ final class WorkspacePanelGitBranchTests: XCTestCase { workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false) var publishCount = 0 - let cancellable = workspace.objectWillChange.sink { _ in + let cancellable = workspace.sidebarObservationPublisher.sink { publishCount += 1 } defer { cancellable.cancel() } + publishCount = 0 let pullRequestURL = URL(string: "https://github.com/manaflow-ai/cmux/pull/2388")! workspace.updatePanelPullRequest( @@ -7037,7 +7124,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertGreaterThan( baselinePublishCount, 0, - "Expected the first focused pull request update to publish workspace changes" + "Expected the first focused pull request update to publish sidebar changes" ) workspace.updatePanelPullRequest( @@ -7052,7 +7139,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual( publishCount, baselinePublishCount, - "Expected identical focused pull request refreshes to avoid extra workspace publishes" + "Expected identical focused pull request refreshes to avoid extra sidebar publishes" ) } @@ -7086,8 +7173,13 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + /// The focused-panel binding (`pullRequest`) and the sidebar list are two + /// different things: the binding follows focus, while the list is the + /// workspace's deduplicated PR rows in pane/tab order — the same all-panel + /// model `sidebarGitBranchesInDisplayOrder()` uses, and what both consumers + /// (`taskStatusSignals`, the control-sidebar snapshot) actually want. @MainActor - func testSidebarPullRequestsTrackFocusedPanelOnly() { + func testSidebarPullRequestsListAllPanelsWhileFocusedBindingTracksFocus() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId, let paneId = workspace.paneId(forPanelId: firstPanelId), @@ -7106,14 +7198,18 @@ final class WorkspacePanelGitBranchTests: XCTestCase { status: .open ) - XCTAssertNil(workspace.pullRequest) - XCTAssertTrue( - workspace.sidebarPullRequestsInDisplayOrder().isEmpty, - "Expected background panel PRs to stay hidden while the focused panel has no PR" + XCTAssertNil(workspace.pullRequest, "The focused panel has no PR, so the binding stays nil") + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder().map(\.number), + [1629], + "The sidebar list covers every panel, not just the focused one" ) workspace.focusPanel(secondPanel.id) + // The list is unchanged because it never depended on focus. (The + // `pullRequest` binding itself lands in the coalesced focus reconcile, + // so it is not observable synchronously here.) XCTAssertEqual( workspace.sidebarPullRequestsInDisplayOrder().map(\.number), [1629] @@ -7510,7 +7606,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } - func testForkConversationContextMenuDefaultActionWorksForCodexSnapshot() throws { + func testForkConversationContextMenuDefaultActionWorksForCodexSnapshot() async throws { // Parity coverage with the Claude path: Codex sessions are also `.supportedWithoutProbe` // and should reach the default right-split path through the context-menu dispatcher. let defaults = UserDefaults.standard @@ -7544,6 +7640,11 @@ final class WorkspacePanelGitBranchTests: XCTestCase { inPane: sourcePaneId ) + let didCreateFork = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + workspace.panels.count == 2 + } + XCTAssertTrue(didCreateFork, "Context menu must create the fork panel") + let forkPanelId = try XCTUnwrap(workspace.focusedPanelId) XCTAssertNotEqual(forkPanelId, sourcePanelId, "Codex fork should focus the new split") let forkPanel = try XCTUnwrap(workspace.terminalPanel(for: forkPanelId)) @@ -7557,7 +7658,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(try paneId(in: split.second), forkPaneUUID) } - func testForkConversationContextMenuNewTabActionCreatesSiblingTab() throws { + func testForkConversationContextMenuNewTabActionCreatesSiblingTab() async throws { // Drive the same code path the bonsplit context menu triggers, end-to-end, // to lock in that the menu wiring stays connected. let workspace = Workspace() @@ -7576,6 +7677,11 @@ final class WorkspacePanelGitBranchTests: XCTestCase { inPane: sourcePaneId ) + let didCreateFork = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + workspace.panels.count == 2 + } + XCTAssertTrue(didCreateFork, "Context menu must create the fork panel") + XCTAssertEqual( workspace.bonsplitController.tabs(inPane: sourcePaneId).count, 2, @@ -7588,7 +7694,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } - func testForkConversationContextMenuPrimaryActionUsesConfiguredDefault() throws { + func testForkConversationContextMenuPrimaryActionUsesConfiguredDefault() async throws { let defaults = UserDefaults.standard let previousValue = defaults.object(forKey: AgentConversationForkDefaultSettings.key) defer { @@ -7617,6 +7723,11 @@ final class WorkspacePanelGitBranchTests: XCTestCase { inPane: sourcePaneId ) + let didCreateFork = await AppKitTestEventPump().waitUntil(timeout: .seconds(5)) { + workspace.panels.count == 2 + } + XCTAssertTrue(didCreateFork, "Context menu must create the fork panel") + XCTAssertEqual( workspace.bonsplitController.tabs(inPane: sourcePaneId).count, 2, diff --git a/tests/test_vm_scp.py b/tests/test_vm_scp.py index b8017d48094f..4fec69a2c26d 100644 --- a/tests/test_vm_scp.py +++ b/tests/test_vm_scp.py @@ -10,7 +10,6 @@ import json import os import pty -import select from pathlib import Path import shlex import socket @@ -25,6 +24,18 @@ def run(argv, **kwargs): return subprocess.run(argv, text=True, capture_output=True, timeout=45, **kwargs) +def drain_terminal(master, sink): + """Collects a pty's output until every process holding its slave has exited.""" + while True: + try: + chunk = os.read(master, 65536) + except OSError: + return + if not chunk: + return + sink.extend(chunk) + + def main(cli): with tempfile.TemporaryDirectory(prefix="scp-", dir="/tmp") as raw: root = Path(raw) @@ -270,28 +281,46 @@ def push(local, remote, *args): wrong_host_key = fails destination = f"human-{tty}-{fails}" master, slave = pty.openpty() if tty else (None, None) + terminal_output = bytearray() + reader = None try: - result = subprocess.run( + process = subprocess.Popen( [cli, "vm", "push", "test-vm", str(payload), destination], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=slave if tty else subprocess.PIPE, timeout=30, + stderr=slave if tty else subprocess.PIPE, ) - stderr = result.stderr or b"" - if master is not None: - while select.select([master], [], [], 0)[0]: - stderr += os.read(master, 65536) + if slave is not None: + # The CLI owns the slave now. A terminal's output queue + # holds only about 1 KiB and the host-key failure report is + # longer, so the master must be drained while the CLI runs + # or its stderr writes block until the timeout. + os.close(slave) + slave = None + reader = threading.Thread(target=drain_terminal, args=(master, terminal_output), daemon=True) + reader.start() + try: + stdout, piped_stderr = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + if reader is not None: + # Returns once every process holding the slave has exited. + reader.join(timeout=10) + assert not reader.is_alive(), "terminal stderr stayed open after the CLI exited" + stderr = piped_stderr if piped_stderr is not None else bytes(terminal_output) finally: if slave is not None: os.close(slave) if master is not None: os.close(master) wrong_host_key = False - assert result.returncode == (1 if fails else 0), stderr + assert process.returncode == (1 if fails else 0), stderr if fails: assert b"Host key verification failed" in stderr, stderr assert b"Cloud diagnostic reference:" in stderr, stderr - assert b"Pushed" not in result.stdout, result.stdout + assert b"Pushed" not in stdout, stdout assert not (guest / destination).exists() else: - assert b"Pushed" in result.stdout and destination.encode() in result.stdout, result.stdout + assert b"Pushed" in stdout and destination.encode() in stdout, stdout assert stderr == b"", stderr assert (guest / destination).read_bytes() == payload.read_bytes() print("PASS SCP human output and errors over pipes and terminals", flush=True)