diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 12e827b1..c3e9b358 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -48,9 +48,10 @@ jobs: echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" - XCODE_VER="$(xcodebuild -version | head -1)" + XCODE_VERSION_OUTPUT="$(xcodebuild -version)" + XCODE_VER="${XCODE_VERSION_OUTPUT%%$'\n'*}" echo "XCODE_VER=$XCODE_VER" >> "$GITHUB_ENV" - echo "$XCODE_VER" + echo "$XCODE_VERSION_OUTPUT" xcrun --sdk macosx --show-sdk-path sw_vers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d24e983..e93274d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -385,3 +385,137 @@ jobs: CMUX_LAG_MAX_CHURN_P95_MS=35 \ CMUX_LAG_KEY_EVENTS=180 \ python3 tests/test_workspace_churn_up_arrow_lag.py + + ui-display-resolution-regression: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install zig + run: | + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version + fi + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-ui-display-resolution-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-ui-display-resolution- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run display resolution churn UI regression + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + HARNESS_DIR="${RUNNER_TEMP}/cmux-display-churn-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$HARNESS_DIR" + PREFIX="${HARNESS_DIR}/cmux-display-churn" + READY_PATH="${PREFIX}.ready" + DISPLAY_ID_PATH="${PREFIX}.id" + START_PATH="${PREFIX}.start" + DONE_PATH="${PREFIX}.done" + LOG_PATH="${PREFIX}.log" + MANIFEST_PATH="${HARNESS_DIR}/cmux-ui-test-display-harness.json" + + rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" "$MANIFEST_PATH" + + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + + /tmp/create-virtual-display \ + --modes 1920x1080,1728x1117,1600x900,1440x810 \ + --ready-path "$READY_PATH" \ + --display-id-path "$DISPLAY_ID_PATH" \ + --start-path "$START_PATH" \ + --done-path "$DONE_PATH" \ + --iterations 40 \ + --interval-ms 40 \ + >"$LOG_PATH" 2>&1 & + VDISPLAY_PID=$! + trap 'kill "$VDISPLAY_PID" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT + + for _ in {1..120}; do + [ -f "$READY_PATH" ] && break + sleep 0.25 + done + [ -f "$READY_PATH" ] || { + echo "Display harness failed to start" >&2 + cat "$LOG_PATH" >&2 || true + exit 1 + } + + cat >"$MANIFEST_PATH" <"$LOG_PATH" 2>&1 & + DISPLAY_VDISPLAY_PID=$! + trap 'kill "${DISPLAY_VDISPLAY_PID:-}" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT + + for _ in {1..120}; do + [ -f "$READY_PATH" ] && break + sleep 0.25 + done + [ -f "$READY_PATH" ] || { + echo "Display harness failed to start" >&2 + cat "$LOG_PATH" >&2 || true + exit 1 + } + + cat >"$MANIFEST_PATH" <&1 || true ) echo "Available devices:" @@ -233,7 +290,7 @@ jobs: fi set +e - OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ -disableAutomaticPackageResolution \ -destination "platform=macOS" \ diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 90ef397b..dac5549c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; + B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; @@ -233,6 +234,7 @@ A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; + B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; @@ -496,6 +498,7 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, + B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -756,6 +759,7 @@ B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, + B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 3026ae95..6077a528 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -143,9 +143,8 @@ _cmux_install_winch_guard() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - # Keep a spacer line so prompt redraw during resize cannot clobber the - # tail of command output that was rendered immediately above the prompt. - builtin print -r -- "" + # Ghostty already marks prompt redraws on SIGWINCH. Writing to the PTY + # here grows the screen and makes resize look like a fresh prompt. return 0 } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4e559abc..b0e05526 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2043,6 +2043,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + private var didSetupDisplayResolutionUITestDiagnostics = false + private var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private struct UITestRenderDiagnosticsSnapshot { + let panelId: UUID + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + } var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? // Keep debug-only windows alive when tests intentionally inject key mismatches. private var debugDetachedContextWindows: [NSWindow] = [] @@ -2343,6 +2355,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if NSApp.windows.isEmpty { self.openNewMainWindow(nil) } + self.moveUITestWindowToTargetDisplayIfNeeded() NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } @@ -2380,6 +2393,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let windows = NSApp.windows let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") + let screenIDs = windows.map { $0.screen?.cmuxDisplayID.map(String.init) ?? "" }.joined(separator: ",") + let targetDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"] ?? "" payload["stage"] = stage payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) @@ -2388,6 +2403,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["windowsCount"] = String(windows.count) payload["windowIdentifiers"] = ids payload["windowVisibleFlags"] = vis + payload["windowScreenDisplayIDs"] = screenIDs + payload["uiTestTargetDisplayID"] = targetDisplayID + if let rawDisplayID = UInt32(targetDisplayID) { + let screenPresent = NSScreen.screens.contains(where: { $0.cmuxDisplayID == rawDisplayID }) + let movedWindow = windows.contains(where: { $0.screen?.cmuxDisplayID == rawDisplayID }) + payload["targetDisplayPresent"] = screenPresent ? "1" : "0" + payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" + } + appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) + appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -2400,6 +2425,160 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return object } + + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + guard let config = socketListenerConfigurationIfEnabled() else { + payload["socketExpectedPath"] = env["CMUX_SOCKET_PATH"] ?? "" + payload["socketMode"] = "off" + payload["socketReady"] = "0" + payload["socketPingResponse"] = "" + payload["socketIsRunning"] = "0" + payload["socketAcceptLoopAlive"] = "0" + payload["socketPathMatches"] = "0" + payload["socketPathExists"] = "0" + payload["socketFailureSignals"] = "socket_disabled" + return + } + + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + var failureSignals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + failureSignals.append("ping_timeout") + } + + payload["socketExpectedPath"] = socketPath + payload["socketMode"] = config.mode.rawValue + payload["socketReady"] = isReady ? "1" : "0" + payload["socketPingResponse"] = pingResponse ?? "" + payload["socketIsRunning"] = health.isRunning ? "1" : "0" + payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" + payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" + payload["socketPathExists"] = health.socketPathExists ? "1" : "0" + payload["socketFailureSignals"] = failureSignals.joined(separator: ",") + } + + private func appendUITestRenderDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } + + guard let renderState = currentUITestRenderDiagnostics() else { + payload["renderStatsAvailable"] = "0" + payload["renderPanelId"] = "" + payload["renderDrawCount"] = "" + payload["renderPresentCount"] = "" + payload["renderLastPresentTime"] = "" + payload["renderWindowVisible"] = "" + payload["renderAppIsActive"] = "" + payload["renderDesiredFocus"] = "" + payload["renderIsFirstResponder"] = "" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return + } + + payload["renderStatsAvailable"] = "1" + payload["renderPanelId"] = renderState.panelId.uuidString + payload["renderDrawCount"] = String(renderState.drawCount) + payload["renderPresentCount"] = String(renderState.presentCount) + payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) + payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" + payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" + payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" + payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + } + + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil + } + + let terminalPanel: TerminalPanel? = { + if let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { + return terminalPanel + } + if let focusedTerminalPanel = workspace.focusedTerminalPanel { + return focusedTerminalPanel + } + return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + }() + + guard let terminalPanel else { return nil } + let stats = terminalPanel.hostedView.debugRenderStats() + return UITestRenderDiagnosticsSnapshot( + panelId: terminalPanel.id, + drawCount: stats.drawCount, + presentCount: stats.presentCount, + lastPresentTime: stats.lastPresentTime, + windowVisible: stats.windowOcclusionVisible, + appIsActive: stats.appIsActive, + desiredFocus: stats.desiredFocus, + isFirstResponder: stats.isFirstResponder + ) + } + + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], + let targetDisplayID = UInt32(rawDisplayID) else { + return + } + + guard let screen = NSScreen.screens.first(where: { $0.cmuxDisplayID == targetDisplayID }) else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") + return + } + + guard let window = NSApp.windows.first else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return + } + + let visibleFrame = screen.visibleFrame + let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) + let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) + let frame = NSRect( + x: visibleFrame.midX - (width / 2), + y: visibleFrame.midY - (height / 2), + width: width, + height: height + ).integral + + window.setFrame(frame, display: true, animate: false) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + if window.screen?.cmuxDisplayID != targetDisplayID, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + return + } + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + } #endif func applicationDidBecomeActive(_ notification: Notification) { @@ -2466,6 +2645,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() + setupDisplayResolutionUITestDiagnosticsIfNeeded() // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. // The automation socket is a core testing primitive, so ensure it's started here when @@ -2482,11 +2662,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent socketPath: SocketControlSettings.socketPath(), accessMode: mode ) + scheduleUITestSocketSanityCheckIfNeeded() } } #endif } +#if DEBUG + private func scheduleUITestSocketSanityCheckIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in + guard let self else { return } + guard let config = self.socketListenerConfigurationIfEnabled() else { + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityDisabled") + return + } + + let expectedPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: expectedPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: expectedPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + if isReady { + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityReady") + return + } + + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityRestart") + self.restartSocketListenerIfEnabled(source: "uiTest.socketSanity") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: "socketSanityPostRestart") + } + } + } + + private func setupDisplayResolutionUITestDiagnosticsIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } + guard !didSetupDisplayResolutionUITestDiagnostics else { return } + didSetupDisplayResolutionUITestDiagnostics = true + + let center = NotificationCenter.default + let observe: (Notification.Name, String) -> Void = { [weak self] name, stage in + guard let self else { return } + let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: stage) + } + } + self.displayResolutionUITestObservers.append(observer) + } + + observe(NSWindow.didResizeNotification, "displayUITest.windowDidResize") + observe(NSWindow.didMoveNotification, "displayUITest.windowDidMove") + observe(NSWindow.didChangeScreenNotification, "displayUITest.windowDidChangeScreen") + observe(NSWindow.didChangeBackingPropertiesNotification, "displayUITest.windowDidChangeBacking") + observe(.terminalSurfaceDidBecomeReady, "displayUITest.terminalSurfaceDidBecomeReady") + observe(.terminalPortalVisibilityDidChange, "displayUITest.terminalPortalVisibilityDidChange") + + writeUITestDiagnosticsIfNeeded(stage: "displayUITest.setup") + } +#endif + private func prepareStartupSessionSnapshotIfNeeded() { guard !didPrepareStartupSessionSnapshot else { return } didPrepareStartupSessionSnapshot = true diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fca38cd5..4d195ac2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1914,7 +1914,10 @@ struct ContentView: View { } .onDisappear { hoveredResizerHandles.remove(handle) - isResizerDragging = false + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + } sidebarDragStartWidth = nil isResizerBandActive = false scheduleSidebarResizerCursorRelease(force: true) @@ -1923,11 +1926,9 @@ struct ContentView: View { DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in if !isResizerDragging { + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() isResizerDragging = true sidebarDragStartWidth = sidebarWidth - #if DEBUG - dlog("sidebar.resizeDragStart") - #endif } activateSidebarResizerCursor() @@ -1942,6 +1943,7 @@ struct ContentView: View { } .onEnded { _ in if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() isResizerDragging = false sidebarDragStartWidth = nil } @@ -2712,12 +2714,20 @@ struct ContentView: View { } // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted // terminals need an explicit post-layout geometry resync. - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + if let observedWindow { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) + } else { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } updateSidebarResizerBandState() }) view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + if let observedWindow { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) + } else { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } updateSidebarResizerBandState() }) @@ -2739,6 +2749,11 @@ struct ContentView: View { }) view = AnyView(view.onDisappear { + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + sidebarDragStartWidth = nil + } removeSidebarResizerPointerMonitor() }) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e6d16ea2..ded6ac00 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6611,6 +6611,9 @@ final class GhosttySurfaceScrollView: NSView { if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } + // NSScrollView can defer clip-view/content-size updates until its own layout pass, + // which makes interactive width changes arrive a queue turn late on Sequoia. + scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() updateFlashPath(style: .standardFocus) synchronizeScrollView() @@ -8834,6 +8837,14 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } + static func shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: Bool, + windowInLiveResize: Bool, + interactiveGeometryResizeActive: Bool + ) -> Bool { + hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive + } + private static func synchronizePortalGeometry( for host: HostContainerView, coordinator: Coordinator @@ -8841,14 +8852,20 @@ struct GhosttyTerminalView: NSViewRepresentable { let geometryRevision = host.geometryRevision guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - if host.inLiveResize || host.window?.inLiveResize == true { + let window = host.window + if shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: host.inLiveResize, + windowInLiveResize: window?.inLiveResize == true, + interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive + ) { TerminalWindowPortalRegistry.synchronizeForAnchor(host) return } // Avoid synchronizing the terminal portal while AppKit is still inside // the current layout turn. Re-entrant syncs here can wedge window resize // handling and leave the app spinning on the wait cursor. - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + guard let window else { return } + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) } func makeNSView(context: Context) -> NSView { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e4b78917..0518e37c 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -567,6 +567,9 @@ private final class SplitDividerOverlayView: NSView { @MainActor final class WindowTerminalPortal: NSObject { +#if DEBUG + static var isPointerDragActiveForTesting = false +#endif private static let tinyHideThreshold: CGFloat = 1 private static let minimumRevealWidth: CGFloat = 24 private static let minimumRevealHeight: CGFloat = 18 @@ -677,10 +680,11 @@ final class WindowTerminalPortal: NSObject { geometryObservers.removeAll() } - private func scheduleExternalGeometrySynchronize() { + fileprivate func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true - let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true) + let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) DispatchQueue.main.async { [weak self] in guard let self else { return } let performSync = { @@ -1427,22 +1431,23 @@ final class WindowTerminalPortal: NSObject { #endif } - if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { - CATransaction.begin() - CATransaction.setDisableActions(true) - hostedView.frame = targetFrame - CATransaction.commit() - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow(reason: "portal.frameChange") - } - if hasFiniteFrame { let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + var geometryChanged = false + CATransaction.begin() + CATransaction.setDisableActions(true) + if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { + hostedView.frame = targetFrame + geometryChanged = true + } if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { - CATransaction.begin() - CATransaction.setDisableActions(true) hostedView.bounds = expectedBounds - CATransaction.commit() + geometryChanged = true + } + CATransaction.commit() + if geometryChanged { + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") } } @@ -1641,14 +1646,25 @@ final class WindowTerminalPortal: NSObject { @MainActor enum TerminalWindowPortalRegistry { +#if DEBUG + static var isPointerDragActiveForTesting = false +#endif private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] private static var hasPendingExternalGeometrySyncForAllWindows = false + private static var interactiveGeometryResizeCount = 0 #if DEBUG private static var blockedBindCount: Int = 0 private static var blockedBindReasons: [String: Int] = [:] #endif + static var isInteractiveGeometryResizeActive: Bool { +#if DEBUG + if Self.isPointerDragActiveForTesting { return true } +#endif + return Self.interactiveGeometryResizeCount > 0 + } + private static func bindBlockReason( expectedSurfaceId: UUID?, expectedGeneration: UInt64?, @@ -1731,6 +1747,15 @@ enum TerminalWindowPortalRegistry { return portal } + private static func existingPortal(for window: NSWindow) -> WindowTerminalPortal? { + if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) + return existing + } + return portalsByWindowId[ObjectIdentifier(window)] + } + static func bind( hostedView: GhosttySurfaceScrollView, to anchorView: NSView, @@ -1789,16 +1814,34 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func scheduleExternalGeometrySynchronize(for window: NSWindow) { + existingPortal(for: window)?.scheduleExternalGeometrySynchronize() + } + + static func beginInteractiveGeometryResize() { + interactiveGeometryResizeCount += 1 + } + + static func endInteractiveGeometryResize() { + interactiveGeometryResizeCount = max(0, interactiveGeometryResizeCount - 1) + } + static func scheduleExternalGeometrySynchronizeForAllWindows() { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true + let isDragEvent = Self.isInteractiveGeometryResizeActive DispatchQueue.main.async { - DispatchQueue.main.async { + let performSync = { Self.hasPendingExternalGeometrySyncForAllWindows = false for portal in Self.portalsByWindowId.values { portal.synchronizeAllEntriesFromExternalGeometryChange() } } + if isDragEvent { + performSync() + } else { + DispatchQueue.main.async(execute: performSync) + } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ca6f5e78..b3be866c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,6 +4,43 @@ import Darwin import Bonsplit import UniformTypeIdentifiers +enum UITestLaunchManifest { + static let argumentName = "-cmuxUITestLaunchManifest" + + struct Payload: Decodable { + let environment: [String: String] + } + + static func applyIfPresent( + arguments: [String] = CommandLine.arguments, + loadData: (String) -> Data? = { path in + try? Data(contentsOf: URL(fileURLWithPath: path)) + }, + applyEnvironment: (String, String) -> Void = { key, value in + setenv(key, value, 1) + } + ) { + guard let path = manifestPath(from: arguments), + let data = loadData(path), + let payload = try? JSONDecoder().decode(Payload.self, from: data) else { + return + } + + for (key, value) in payload.environment { + applyEnvironment(key, value) + } + } + + static func manifestPath(from arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: argumentName) else { return nil } + let valueIndex = arguments.index(after: index) + guard valueIndex < arguments.endIndex else { return nil } + + let rawPath = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines) + return rawPath.isEmpty ? nil : rawPath + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -45,6 +82,8 @@ struct cmuxApp: App { } init() { + UITestLaunchManifest.applyIfPresent() + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { Self.terminateForMissingLaunchTag() } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 5cca92b3..f2cc880e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1793,6 +1793,43 @@ final class SocketControlSettingsTests: XCTestCase { } } +final class UITestLaunchManifestTests: XCTestCase { + func testManifestPathReadsArgumentValue() { + XCTAssertEqual( + UITestLaunchManifest.manifestPath( + from: ["cmux", "-cmuxUITestLaunchManifest", "/tmp/cmux-ui-test-launch.json"] + ), + "/tmp/cmux-ui-test-launch.json" + ) + } + + func testManifestPathReturnsNilWithoutValue() { + XCTAssertNil( + UITestLaunchManifest.manifestPath( + from: ["cmux", "-cmuxUITestLaunchManifest"] + ) + ) + } + + func testApplyIfPresentDecodesEnvironmentPayload() { + let payload = """ + {"environment":{"CMUX_TAG":"ui-tests-display","CMUX_SOCKET_PATH":"/tmp/cmux-ui-tests.sock"}} + """.data(using: .utf8)! + var applied: [String: String] = [:] + + UITestLaunchManifest.applyIfPresent( + arguments: ["cmux", UITestLaunchManifest.argumentName, "/tmp/cmux-ui-test-launch.json"], + loadData: { _ in payload }, + applyEnvironment: { key, value in + applied[key] = value + } + ) + + XCTAssertEqual(applied["CMUX_TAG"], "ui-tests-display") + XCTAssertEqual(applied["CMUX_SOCKET_PATH"], "/tmp/cmux-ui-tests.sock") + } +} + final class PostHogAnalyticsPropertiesTests: XCTestCase { func testDailyActivePropertiesIncludeVersionAndBuild() { let properties = PostHogAnalytics.dailyActiveProperties( @@ -2300,6 +2337,20 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output) } + func testShellIntegrationWinchGuardDoesNotPrintSpacerLineOnResize() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + print -r -- BEFORE + TRAPWINCH + print -r -- AFTER + """ + ) + + XCTAssertEqual(output, "BEFORE\nAFTER", output) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 272d8b5a..9faffe0a 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1756,6 +1756,14 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { window.contentView?.layoutSubtreeIfNeeded() } + private func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -2189,7 +2197,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Initial hit-testing should resolve the portal-hosted terminal at its original window position" ) - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) DispatchQueue.main.async { shiftedContainer.frame.origin.x += 72 contentView.layoutSubtreeIfNeeded() @@ -2226,6 +2234,306 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" ) } + + func testScheduledExternalGeometrySyncKeepsDragDrivenResizeResponsive() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + realizeWindowLayout(window) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() + defer { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + } + + do { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } + + drainMainQueue() + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertGreaterThan( + shiftedWindowPoint.x, + originalWindowPoint.x + 1, + "The drag handler should shift the anchor to the right" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "Drag-driven geometry sync should clear the stale portal location on the next main-queue turn" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Drag-driven geometry sync should update the portal-hosted terminal without waiting an extra queue turn" + ) + } + + func testDragDrivenSidebarResizeDoesNotScheduleLateSecondTerminalResize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 420, height: 220)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: shiftedContainer.bounds) + anchor.autoresizingMask = [.width, .height] + shiftedContainer.addSubview(anchor) + + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + realizeWindowLayout(window) + let originalHostedFrame = hosted.frame + + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() + defer { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + } + + shiftedContainer.frame.origin.x += 72 + shiftedContainer.frame.size.width -= 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) + + drainMainQueue() + + let firstPassHostedFrame = hosted.frame + XCTAssertGreaterThan( + firstPassHostedFrame.minX, + originalHostedFrame.minX + 1, + "The sidebar drag should shift the hosted terminal on the first window-scoped sync pass" + ) + XCTAssertLessThan( + firstPassHostedFrame.width, + originalHostedFrame.width - 1, + "The sidebar drag should resize the hosted terminal on the first window-scoped sync pass" + ) + + drainMainQueue() + + let secondPassHostedFrame = hosted.frame + XCTAssertEqual( + secondPassHostedFrame.minX, + firstPassHostedFrame.minX, + accuracy: 0.5, + "Interactive sidebar resizes should not land a second delayed horizontal terminal shift on the next queue turn" + ) + XCTAssertEqual( + secondPassHostedFrame.width, + firstPassHostedFrame.width, + accuracy: 0.5, + "Interactive sidebar resizes should not land a second delayed terminal resize on the next queue turn" + ) + } + + func testWindowScopedExternalGeometrySyncDoesNotRefreshOtherWindows() { + let firstWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow) + firstWindow.orderOut(nil) + } + + let secondWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: secondWindow) + secondWindow.orderOut(nil) + } + + let firstSurface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let secondSurface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + + guard let firstContentView = firstWindow.contentView, + let secondContentView = secondWindow.contentView else { + XCTFail("Expected content views") + return + } + + let firstContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + firstContentView.addSubview(firstContainer) + let firstAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + firstContainer.addSubview(firstAnchor) + + let secondContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + secondContentView.addSubview(secondContainer) + let secondAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + secondContainer.addSubview(secondAnchor) + + TerminalWindowPortalRegistry.bind( + hostedView: firstSurface.hostedView, + to: firstAnchor, + visibleInUI: true, + expectedSurfaceId: firstSurface.id, + expectedGeneration: firstSurface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.bind( + hostedView: secondSurface.hostedView, + to: secondAnchor, + visibleInUI: true, + expectedSurfaceId: secondSurface.id, + expectedGeneration: secondSurface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(firstAnchor) + TerminalWindowPortalRegistry.synchronizeForAnchor(secondAnchor) + realizeWindowLayout(firstWindow) + realizeWindowLayout(secondWindow) + + let originalFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil) + let originalSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil) + + firstContainer.frame.origin.x += 72 + secondContainer.frame.origin.x += 88 + firstContentView.layoutSubtreeIfNeeded() + secondContentView.layoutSubtreeIfNeeded() + firstWindow.displayIfNeeded() + secondWindow.displayIfNeeded() + + let shiftedFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil) + let shiftedSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil) + let retiredFirstPoint = NSPoint( + x: (originalFirstFrameInWindow.minX + shiftedFirstFrameInWindow.minX) / 2, + y: shiftedFirstFrameInWindow.midY + ) + let shiftedFirstPoint = NSPoint( + x: (originalFirstFrameInWindow.maxX + shiftedFirstFrameInWindow.maxX) / 2, + y: shiftedFirstFrameInWindow.midY + ) + let retiredSecondPoint = NSPoint( + x: (originalSecondFrameInWindow.minX + shiftedSecondFrameInWindow.minX) / 2, + y: shiftedSecondFrameInWindow.midY + ) + let shiftedSecondPoint = NSPoint( + x: (originalSecondFrameInWindow.maxX + shiftedSecondFrameInWindow.maxX) / 2, + y: shiftedSecondFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), + "First window should remain stale until its scheduled external geometry sync runs" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow), + "Second window should remain stale until its scheduled external geometry sync runs" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow), + "Before syncing, unrelated windows should still report the stale portal location" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: firstWindow) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredFirstPoint, in: firstWindow), + "Window-scoped sync should clear the stale location in the requested window" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), + "Window-scoped sync should refresh the requested window" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow), + "Window-scoped sync should not refresh unrelated windows" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow), + "Unrelated windows should retain their stale geometry until their own sync runs" + ) + } } @@ -2370,6 +2678,17 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) ) } + + func testInteractiveGeometryResizeUsesImmediatePortalSyncDecision() { + XCTAssertTrue( + GhosttyTerminalView.shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: false, + windowInLiveResize: false, + interactiveGeometryResizeActive: true + ), + "Interactive resize should use the immediate portal sync path" + ) + } } diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift new file mode 100644 index 00000000..579ae221 --- /dev/null +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -0,0 +1,386 @@ +import XCTest +import Foundation + +final class DisplayResolutionRegressionUITests: XCTestCase { + private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" + private var launchTag = "" + private var diagnosticsPath = "" + private var displayReadyPath = "" + private var displayIDPath = "" + private var displayStartPath = "" + private var displayDonePath = "" + private var helperBinaryPath = "" + private var helperLogPath = "" + private var launchedApp: XCUIApplication? + private var helperProcess: Process? + + override func setUp() { + super.setUp() + continueAfterFailure = false + + let token = UUID().uuidString + launchTag = "ui-tests-display-resolution-\(token.prefix(8))" + diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" + displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" + displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" + displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" + displayDonePath = "/tmp/cmux-ui-test-display-done-\(token)" + helperBinaryPath = "/tmp/cmux-ui-test-display-helper-\(token)" + helperLogPath = "/tmp/cmux-ui-test-display-helper-\(token).log" + + removeTestArtifacts() + } + + override func tearDown() { + terminateLaunchedAppIfNeeded() + helperProcess?.terminate() + helperProcess?.waitUntilExit() + helperProcess = nil + removeTestArtifacts() + super.tearDown() + } + + func testRapidDisplayResolutionChangesKeepTerminalResponsive() throws { + try prepareDisplayHarnessIfNeeded() + + XCTAssertTrue(waitForFile(atPath: displayReadyPath, timeout: 12.0), "Expected display harness ready file at \(displayReadyPath)") + guard let targetDisplayID = readTrimmedFile(atPath: displayIDPath), !targetDisplayID.isEmpty else { + XCTFail("Missing target display ID at \(displayIDPath)") + return + } + + try launchAppProcess(targetDisplayID: targetDisplayID) + XCTAssertTrue( + waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" + ) + + guard let baselineStats = waitForRenderStats(timeout: 8.0) else { + XCTFail("Missing initial render stats. diagnostics=\(loadDiagnostics() ?? [:])") + return + } + let baselinePresentCount = baselineStats.presentCount + var maxPresentCount = baselinePresentCount + var maxDiagnosticsUpdatedAt = baselineStats.diagnosticsUpdatedAt + var lastStats = baselineStats + + do { + try Data("start\n".utf8).write(to: URL(fileURLWithPath: displayStartPath), options: .atomic) + } catch { + XCTFail("Expected start signal file to be created at \(displayStartPath): \(error)") + return + } + + let deadline = Date().addingTimeInterval(30.0) + while Date() < deadline { + if let stats = loadRenderStats() { + lastStats = stats + maxPresentCount = max(maxPresentCount, stats.presentCount) + maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, stats.diagnosticsUpdatedAt) + } + + let doneMarker = readTrimmedFile(atPath: displayDonePath) + if doneMarker == "done" && maxPresentCount >= baselinePresentCount + 8 { + break + } + if let doneMarker, doneMarker.hasPrefix("error:") { + XCTFail("Display churn helper failed: \(doneMarker). log=\(readTrimmedFile(atPath: helperLogPath) ?? "")") + return + } + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + } + + XCTAssertEqual( + readTrimmedFile(atPath: displayDonePath), + "done", + "Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "")" + ) + + guard let finalStats = waitForRenderStats(timeout: 6.0) else { + XCTFail("Expected render stats after display churn. diagnostics=\(loadDiagnostics() ?? [:])") + return + } + + maxPresentCount = max(maxPresentCount, finalStats.presentCount) + maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, finalStats.diagnosticsUpdatedAt) + + XCTAssertGreaterThanOrEqual( + maxPresentCount - baselinePresentCount, + 8, + "Expected terminal presents to keep advancing during display churn. baseline=\(baselineStats) last=\(lastStats) final=\(finalStats)" + ) + XCTAssertGreaterThan( + maxDiagnosticsUpdatedAt, + baselineStats.diagnosticsUpdatedAt, + "Expected render diagnostics to keep updating during display churn. baseline=\(baselineStats) final=\(finalStats)" + ) + } + + private func prepareDisplayHarnessIfNeeded() throws { + let env = ProcessInfo.processInfo.environment + if let externalHarness = loadExternalHarnessFromEnvironment(env) ?? loadExternalHarnessFromManifest() { + displayReadyPath = externalHarness.readyPath + displayIDPath = externalHarness.displayIDPath + displayStartPath = externalHarness.startPath + displayDonePath = externalHarness.donePath + if let logPath = externalHarness.logPath, !logPath.isEmpty { + helperLogPath = logPath + } + return + } + + try buildDisplayHelper() + try launchDisplayHelper() + } + + private func loadExternalHarnessFromEnvironment(_ env: [String: String]) -> ExternalDisplayHarness? { + guard let readyPath = env["CMUX_UI_TEST_DISPLAY_READY_PATH"], !readyPath.isEmpty, + let displayIDPath = env["CMUX_UI_TEST_DISPLAY_ID_PATH"], !displayIDPath.isEmpty, + let startPath = env["CMUX_UI_TEST_DISPLAY_START_PATH"], !startPath.isEmpty, + let donePath = env["CMUX_UI_TEST_DISPLAY_DONE_PATH"], !donePath.isEmpty else { + return nil + } + + return ExternalDisplayHarness( + readyPath: readyPath, + displayIDPath: displayIDPath, + startPath: startPath, + donePath: donePath, + logPath: env["CMUX_UI_TEST_DISPLAY_LOG_PATH"] + ) + } + + private func loadExternalHarnessFromManifest() -> ExternalDisplayHarness? { + let manifestURL = URL(fileURLWithPath: displayHarnessManifestPath) + guard let data = try? Data(contentsOf: manifestURL) else { + return nil + } + return try? JSONDecoder().decode(ExternalDisplayHarness.self, from: data) + } + + private func buildDisplayHelper() throws { + let sourceURL = repoRootURL.appendingPathComponent("scripts/create-virtual-display.m") + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/clang") + proc.arguments = [ + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-o", helperBinaryPath, + sourceURL.path, + ] + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + + try proc.run() + proc.waitUntilExit() + + guard proc.terminationStatus == 0 else { + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw NSError(domain: "DisplayResolutionRegressionUITests", code: Int(proc.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: "Failed to build display helper: \(stderr)" + ]) + } + } + + private func launchDisplayHelper() throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: helperBinaryPath) + proc.arguments = [ + "--modes", "1920x1080,1728x1117,1600x900,1440x810", + "--ready-path", displayReadyPath, + "--display-id-path", displayIDPath, + "--start-path", displayStartPath, + "--done-path", displayDonePath, + "--iterations", "40", + "--interval-ms", "40", + ] + + let logHandle = FileHandle(forWritingAtPath: helperLogPath) ?? { + FileManager.default.createFile(atPath: helperLogPath, contents: nil) + return FileHandle(forWritingAtPath: helperLogPath) + }() + proc.standardOutput = logHandle + proc.standardError = logHandle + + try proc.run() + helperProcess = proc + } + + private func launchAppProcess(targetDisplayID: String) throws { + let app = XCUIApplication() + for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) { + app.launchEnvironment[key] = value + } + app.launch() + guard ensureForegroundAfterLaunch(app, timeout: 12.0) else { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)" + ]) + } + launchedApp = app + } + + private func launchEnvironment(targetDisplayID: String) -> [String: String] { + [ + "CMUX_UI_TEST_MODE": "1", + "CMUX_UI_TEST_DIAGNOSTICS_PATH": diagnosticsPath, + "CMUX_UI_TEST_DISPLAY_RENDER_STATS": "1", + "CMUX_UI_TEST_TARGET_DISPLAY_ID": targetDisplayID, + "CMUX_TAG": launchTag, + ] + } + + private func terminateLaunchedAppIfNeeded() { + guard let launchedApp else { return } + defer { self.launchedApp = nil } + + if launchedApp.state == .notRunning { + return + } + + launchedApp.terminate() + _ = launchedApp.wait(for: .notRunning, timeout: 5.0) + } + + private func launchedAppDiagnostics() -> String { + guard let launchedApp else { return "not-launched" } + return "state=\(launchedApp.state.rawValue)" + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + + private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + guard let diagnostics = self.loadDiagnostics() else { return false } + return diagnostics["targetDisplayMoveSucceeded"] == "1" && + diagnostics["windowScreenDisplayIDs"]?.contains(targetDisplayID) == true + } + } + + private func waitForRenderStats(timeout: TimeInterval) -> RenderStats? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let stats = loadRenderStats() { + return stats + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return loadRenderStats() + } + + private func loadRenderStats() -> RenderStats? { + guard let diagnostics = loadDiagnostics() else { return nil } + return RenderStats(diagnostics: diagnostics) + } + + private func loadDiagnostics() -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object + } + + private func waitForCondition(timeout: TimeInterval, pollInterval: TimeInterval = 0.15, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + return condition() + } + + private func waitForFile(atPath path: String, timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + FileManager.default.fileExists(atPath: path) + } + } + + private func readTrimmedFile(atPath path: String) -> String? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let value = String(data: data, encoding: .utf8) else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private var repoRootURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private func removeTestArtifacts() { + for path in [ + diagnosticsPath, + displayReadyPath, + displayIDPath, + displayStartPath, + displayDonePath, + helperBinaryPath, + helperLogPath, + ] { + guard !path.isEmpty else { continue } + try? FileManager.default.removeItem(atPath: path) + } + } + + private struct RenderStats: CustomStringConvertible { + let panelId: String + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + let diagnosticsUpdatedAt: Double + + init?(diagnostics: [String: String]) { + guard diagnostics["renderStatsAvailable"] == "1", + let panelId = diagnostics["renderPanelId"], !panelId.isEmpty, + let drawCount = Int(diagnostics["renderDrawCount"] ?? ""), + let presentCount = Int(diagnostics["renderPresentCount"] ?? ""), + let lastPresentTime = Double(diagnostics["renderLastPresentTime"] ?? ""), + let diagnosticsUpdatedAt = Double(diagnostics["renderDiagnosticsUpdatedAt"] ?? "") else { + return nil + } + + self.panelId = panelId + self.drawCount = drawCount + self.presentCount = presentCount + self.lastPresentTime = lastPresentTime + self.windowVisible = diagnostics["renderWindowVisible"] == "1" + self.appIsActive = diagnostics["renderAppIsActive"] == "1" + self.desiredFocus = diagnostics["renderDesiredFocus"] == "1" + self.isFirstResponder = diagnostics["renderIsFirstResponder"] == "1" + self.diagnosticsUpdatedAt = diagnosticsUpdatedAt + } + + var description: String { + "panel=\(panelId) draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) visible=\(windowVisible) active=\(appIsActive) desiredFocus=\(desiredFocus) firstResponder=\(isFirstResponder) updatedAt=\(String(format: "%.3f", diagnosticsUpdatedAt))" + } + } + + private struct ExternalDisplayHarness: Decodable { + let readyPath: String + let displayIDPath: String + let startPath: String + let donePath: String + let logPath: String? + } +} diff --git a/scripts/create-virtual-display.m b/scripts/create-virtual-display.m index d3df1bae..f87ab2bd 100644 --- a/scripts/create-virtual-display.m +++ b/scripts/create-virtual-display.m @@ -1,11 +1,14 @@ // Creates a virtual display on headless macOS (CI runners without a physical monitor). // Uses the private CGVirtualDisplay API from CoreGraphics. -// The display stays alive as long as this process runs. +// The display stays alive as long as this process runs and can optionally churn +// through multiple display modes after a start signal file appears. // // Build: clang -framework Foundation -framework CoreGraphics -o create-virtual-display create-virtual-display.m // Usage: ./create-virtual-display & #import +#import +#import #import // Private CoreGraphics classes (declared here since they're not in public headers) @@ -35,10 +38,141 @@ @property (nonatomic, readonly) unsigned int displayID; @end +static NSArray *> *defaultModeSpecs(void) { + return @[ + @{@"width": @1920, @"height": @1080}, + ]; +} + +static void writeString(NSString *value, NSString *path) { + if (path.length == 0) { return; } + NSError *error = nil; + BOOL ok = [value writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:&error]; + if (!ok && error) { + fprintf(stderr, "ERROR: Failed to write %s (%s)\n", path.UTF8String, error.localizedDescription.UTF8String); + } +} + +static NSDictionary *parseModeSpec(NSString *raw) { + NSArray *parts = [raw.lowercaseString componentsSeparatedByString:@"x"]; + if (parts.count != 2) { return nil; } + + NSInteger width = parts[0].integerValue; + NSInteger height = parts[1].integerValue; + if (width <= 0 || height <= 0) { return nil; } + + return @{ + @"width": @(width), + @"height": @(height), + }; +} + +static NSArray *> *parseModeList(NSString *raw) { + if (raw.length == 0) { return defaultModeSpecs(); } + + NSMutableArray *> *modes = [NSMutableArray array]; + for (NSString *token in [raw componentsSeparatedByString:@","]) { + NSString *trimmed = [token stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmed.length == 0) { continue; } + NSDictionary *parsed = parseModeSpec(trimmed); + if (!parsed) { + fprintf(stderr, "ERROR: Invalid mode spec: %s\n", trimmed.UTF8String); + return nil; + } + [modes addObject:parsed]; + } + + if (modes.count == 0) { + return defaultModeSpecs(); + } + return modes; +} + +static NSString *modeLabel(CGDisplayModeRef mode) { + return [NSString stringWithFormat:@"%zux%zu", CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)]; +} + +static NSArray *resolveRequestedModes(CGDirectDisplayID displayID, NSArray *> *requestedModes) { + NSArray *availableModes = CFBridgingRelease(CGDisplayCopyAllDisplayModes(displayID, NULL)); + if (availableModes.count == 0) { + fprintf(stderr, "ERROR: No CoreGraphics display modes found for display %u\n", displayID); + return nil; + } + + NSMutableArray *resolved = [NSMutableArray array]; + for (NSDictionary *modeSpec in requestedModes) { + size_t requestedWidth = modeSpec[@"width"].unsignedIntegerValue; + size_t requestedHeight = modeSpec[@"height"].unsignedIntegerValue; + + id matched = nil; + for (id candidate in availableModes) { + CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate; + if (CGDisplayModeGetWidth(mode) == requestedWidth && + CGDisplayModeGetHeight(mode) == requestedHeight) { + matched = candidate; + break; + } + } + + if (!matched) { + fprintf(stderr, "ERROR: Requested display mode %zux%zu not available\n", requestedWidth, requestedHeight); + fprintf(stderr, "Available modes:"); + for (id candidate in availableModes) { + CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate; + fprintf(stderr, " %s", modeLabel(mode).UTF8String); + } + fprintf(stderr, "\n"); + return nil; + } + + [resolved addObject:matched]; + } + + return resolved; +} + +static NSString *argumentValue(NSArray *arguments, NSString *flag) { + NSString *prefix = [flag stringByAppendingString:@"="]; + for (NSUInteger i = 0; i < arguments.count; i += 1) { + NSString *arg = arguments[i]; + if ([arg isEqualToString:flag]) { + if (i + 1 < arguments.count) { + return arguments[i + 1]; + } + return @""; + } + if ([arg hasPrefix:prefix]) { + return [arg substringFromIndex:prefix.length]; + } + } + return nil; +} + int main(int argc, const char *argv[]) { @autoreleasepool { - unsigned int width = 1920; - unsigned int height = 1080; + NSArray *arguments = [[NSProcessInfo processInfo] arguments]; + + NSString *modesArgument = argumentValue(arguments, @"--modes"); + NSArray *> *modeSpecs = parseModeList(modesArgument); + if (!modeSpecs) { + return 1; + } + + NSString *readyPath = argumentValue(arguments, @"--ready-path") ?: @""; + NSString *displayIDPath = argumentValue(arguments, @"--display-id-path") ?: @""; + NSString *startPath = argumentValue(arguments, @"--start-path") ?: @""; + NSString *donePath = argumentValue(arguments, @"--done-path") ?: @""; + NSInteger iterations = MAX(0, [argumentValue(arguments, @"--iterations") integerValue]); + NSString *intervalArgument = argumentValue(arguments, @"--interval-ms"); + NSInteger intervalMs = intervalArgument.length > 0 ? intervalArgument.integerValue : 40; + useconds_t intervalMicros = (useconds_t)(MAX(1, intervalMs) * 1000); + + unsigned int width = 0; + unsigned int height = 0; + for (NSDictionary *spec in modeSpecs) { + width = MAX(width, spec[@"width"].unsignedIntValue); + height = MAX(height, spec[@"height"].unsignedIntValue); + } // Verify the private classes exist if (!NSClassFromString(@"CGVirtualDisplay")) { @@ -46,11 +180,16 @@ int main(int argc, const char *argv[]) { return 1; } - // Create display mode - CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width height:height refreshRate:60.0]; - if (!mode) { - fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n"); - return 1; + NSMutableArray *modes = [NSMutableArray array]; + for (NSDictionary *spec in modeSpecs) { + CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:spec[@"width"].unsignedIntValue + height:spec[@"height"].unsignedIntValue + refreshRate:60.0]; + if (!mode) { + fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n"); + return 1; + } + [modes addObject:mode]; } // Configure descriptor @@ -74,7 +213,7 @@ int main(int argc, const char *argv[]) { // Apply settings with display mode CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init]; settings.hiDPI = 0; - settings.modes = @[mode]; + settings.modes = modes; BOOL ok = [display applySettings:settings]; if (!ok) { @@ -85,6 +224,45 @@ int main(int argc, const char *argv[]) { printf("Virtual display created: %ux%u@60Hz (displayID: %u)\n", width, height, display.displayID); printf("PID: %d\n", getpid()); fflush(stdout); + writeString([NSString stringWithFormat:@"%u\n", display.displayID], displayIDPath); + writeString(@"ready\n", readyPath); + + if (iterations > 0 && modeSpecs.count > 1) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + if (startPath.length > 0) { + while (![[NSFileManager defaultManager] fileExistsAtPath:startPath]) { + usleep(20 * 1000); + } + } + + NSArray *resolvedModes = resolveRequestedModes(display.displayID, modeSpecs); + if (resolvedModes.count < 2) { + writeString(@"error:no_modes\n", donePath); + return; + } + + CGError setError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)resolvedModes.firstObject, NULL); + if (setError != kCGErrorSuccess) { + fprintf(stderr, "ERROR: Failed to set initial display mode (%d)\n", setError); + writeString([NSString stringWithFormat:@"error:%d\n", setError], donePath); + return; + } + + for (NSInteger i = 0; i < iterations; i += 1) { + NSUInteger targetIndex = (NSUInteger)((i + 1) % resolvedModes.count); + id targetMode = resolvedModes[targetIndex]; + CGError churnError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)targetMode, NULL); + if (churnError != kCGErrorSuccess) { + fprintf(stderr, "ERROR: Failed to switch display mode at iteration %ld (%d)\n", (long)i, churnError); + writeString([NSString stringWithFormat:@"error:%d\n", churnError], donePath); + return; + } + usleep(intervalMicros); + } + + writeString(@"done\n", donePath); + }); + } // Keep alive so the display persists dispatch_main(); diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index c3a5281c..5e22c00f 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -39,5 +39,18 @@ if ! awk ' exit 1 fi +# ui-display-resolution-regression: must use WarpBuild runner with fork guard (paid runner) +if ! awk ' + /^ ui-display-resolution-regression:/ { in_tests=1; next } + in_tests && /^ [^[:space:]]/ { in_tests=0 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } + in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_warp && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: ui-display-resolution-regression block must keep both warp-macos-15-arm64-6x runner and fork guard" + exit 1 +fi + echo "PASS: tests WarpBuild runner fork guard is present" echo "PASS: tests-build-and-lag WarpBuild runner fork guard is present" +echo "PASS: ui-display-resolution-regression WarpBuild runner fork guard is present" diff --git a/vendor/bonsplit b/vendor/bonsplit index efa23f4c..02fa188c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d +Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795