Fix laggy terminal sync during sidebar drags (#1598)
* Fix sidebar drag terminal resize lag * Add display resolution churn regression * Prelaunch display churn helper in e2e workflow * Use manifest handoff for display churn UI test * Fix e2e display churn harness startup * Resolve display churn UI test socket path * Use marker-based socket discovery in display UI test * Add failing sidebar drag portal regression tests * Fix sidebar drag terminal portal resize lag * Add failing scoped resize regression tests * Fix terminal portal resize scheduling lag * Add failing zsh resize prompt regression test * Fix zsh resize prompt duplication * Fix Sequoia sidebar resize regression * Guard display-resolution CI runner * Run display-resolution CI on WarpBuild * Allow backgrounded display regression app launch * Launch display regression app directly * Launch display regression app via NSWorkspace * Load display regression launch env from manifest * Write display regression manifest in runner temp dir * Write display regression manifest in shared tmp * Write display regression manifest in repo scratch dir * Launch display regression app with explicit env * Avoid xcodebuild broken pipe in compat CI * Launch display regression via XCUIApplication * Harden display regression socket readiness * Trust display socket diagnostics path * Replace display socket probe with render diagnostics * Write display churn start marker atomically * Move display churn harness out of /tmp --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
629b63dfb8
commit
798c1fbc42
16 changed files with 1537 additions and 41 deletions
5
.github/workflows/ci-macos-compat.yml
vendored
5
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
134
.github/workflows/ci.yml
vendored
134
.github/workflows/ci.yml
vendored
|
|
@ -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" <<EOF
|
||||
{"readyPath":"$READY_PATH","displayIDPath":"$DISPLAY_ID_PATH","startPath":"$START_PATH","donePath":"$DONE_PATH","logPath":"$LOG_PATH"}
|
||||
EOF
|
||||
echo "Display harness manifest: $MANIFEST_PATH"
|
||||
cat "$MANIFEST_PATH"
|
||||
|
||||
CMUX_UI_TEST_DISPLAY_READY_PATH="$READY_PATH" \
|
||||
CMUX_UI_TEST_DISPLAY_ID_PATH="$DISPLAY_ID_PATH" \
|
||||
CMUX_UI_TEST_DISPLAY_START_PATH="$START_PATH" \
|
||||
CMUX_UI_TEST_DISPLAY_DONE_PATH="$DONE_PATH" \
|
||||
CMUX_UI_TEST_DISPLAY_LOG_PATH="$LOG_PATH" \
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" \
|
||||
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
|
||||
test
|
||||
|
|
|
|||
61
.github/workflows/test-e2e.yml
vendored
61
.github/workflows/test-e2e.yml
vendored
|
|
@ -108,6 +108,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Create virtual display
|
||||
if: ${{ inputs.test_filter != 'DisplayResolutionRegressionUITests' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Display before ==="
|
||||
|
|
@ -204,8 +205,64 @@ jobs:
|
|||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER"
|
||||
DISPLAY_ENV_PREFIX=()
|
||||
|
||||
# Start recording right before the test (after build/resolve)
|
||||
if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then
|
||||
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 &
|
||||
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" <<EOF
|
||||
{"readyPath":"$READY_PATH","displayIDPath":"$DISPLAY_ID_PATH","startPath":"$START_PATH","donePath":"$DONE_PATH","logPath":"$LOG_PATH"}
|
||||
EOF
|
||||
echo "Display harness manifest: $MANIFEST_PATH"
|
||||
cat "$MANIFEST_PATH"
|
||||
|
||||
DISPLAY_ENV_PREFIX=(
|
||||
CMUX_UI_TEST_DISPLAY_READY_PATH="$READY_PATH"
|
||||
CMUX_UI_TEST_DISPLAY_ID_PATH="$DISPLAY_ID_PATH"
|
||||
CMUX_UI_TEST_DISPLAY_START_PATH="$START_PATH"
|
||||
CMUX_UI_TEST_DISPLAY_DONE_PATH="$DONE_PATH"
|
||||
CMUX_UI_TEST_DISPLAY_LOG_PATH="$LOG_PATH"
|
||||
)
|
||||
fi
|
||||
|
||||
# Start recording right before the test (after build/resolve).
|
||||
# The display churn regression creates its own virtual display above,
|
||||
# so recording must start after that harness is ready.
|
||||
if [ "$RECORD_VIDEO" = "true" ]; then
|
||||
DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&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" \
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
|
||||
B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; };
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
386
cmuxUITests/DisplayResolutionRegressionUITests.swift
Normal file
386
cmuxUITests/DisplayResolutionRegressionUITests.swift
Normal file
|
|
@ -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) ?? "<missing>")")
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
readTrimmedFile(atPath: displayDonePath),
|
||||
"done",
|
||||
"Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "<missing>")"
|
||||
)
|
||||
|
||||
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?
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <unistd.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// 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<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *parseModeSpec(NSString *raw) {
|
||||
NSArray<NSString *> *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<NSDictionary<NSString *, NSNumber *> *> *parseModeList(NSString *raw) {
|
||||
if (raw.length == 0) { return defaultModeSpecs(); }
|
||||
|
||||
NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *modes = [NSMutableArray array];
|
||||
for (NSString *token in [raw componentsSeparatedByString:@","]) {
|
||||
NSString *trimmed = [token stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if (trimmed.length == 0) { continue; }
|
||||
NSDictionary<NSString *, NSNumber *> *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<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *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<NSString *> *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<NSString *> *arguments = [[NSProcessInfo processInfo] arguments];
|
||||
|
||||
NSString *modesArgument = argumentValue(arguments, @"--modes");
|
||||
NSArray<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *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<NSString *, NSNumber *> *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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d
|
||||
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
|
||||
Loading…
Add table
Add a link
Reference in a new issue