diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index a0c72b11..c3e9b358 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -15,11 +15,11 @@ jobs: matrix: include: - os: warp-macos-15-arm64-6x - timeout: 20 + timeout: 30 smoke: true skip_zig: false - os: warp-macos-26-arm64-6x - timeout: 20 + timeout: 30 smoke: false skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26 runs-on: ${{ matrix.os }} @@ -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 @@ -133,8 +134,9 @@ jobs: } set +e - OUTPUT=$(run_unit_tests) - EXIT_CODE=$? + run_unit_tests | tee /tmp/test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/test-output.txt) set -e # SwiftPM binary artifact resolution can occasionally fail on ephemeral @@ -145,12 +147,12 @@ jobs: mkdir -p ~/Library/Caches/org.swift.swiftpm rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* set +e - OUTPUT=$(run_unit_tests) - EXIT_CODE=$? + run_unit_tests | tee /tmp/test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/test-output.txt) set -e fi - echo "$OUTPUT" if [ "$EXIT_CODE" -ne 0 ]; then SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) if echo "$SUMMARY" | grep -q "(0 unexpected)"; then 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:" @@ -232,13 +289,22 @@ jobs: fi fi + XCODEBUILD_CMD=( + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" + -disableAutomaticPackageResolution + -destination "platform=macOS" + -maximum-test-execution-time-allowance "$TEST_TIMEOUT" + $ONLY_TESTING + test + ) + set +e - OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -maximum-test-execution-time-allowance "$TEST_TIMEOUT" \ - $ONLY_TESTING test 2>&1) + if [ "${#DISPLAY_ENV_PREFIX[@]}" -gt 0 ]; then + OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" "${XCODEBUILD_CMD[@]}" 2>&1) + else + OUTPUT=$("${XCODEBUILD_CMD[@]}" 2>&1) + fi EXIT_CODE=$? set -e diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index b8c4d705..e389dc92 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -97,7 +97,7 @@ jobs: url "https://github.com/manaflow-ai/cmux/releases/download/v#{version}/cmux-macos.dmg" name "cmux" desc "Lightweight native macOS terminal with vertical tabs for AI coding agents" - homepage "https://cmux.dev" + homepage "https://cmux.com" livecheck do url :url diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c6495bbb..7b4e4f5a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10720,7 +10720,7 @@ struct CMUXCLI { print() print(shortcuts) print() - print(" \(bold)Docs\(reset)\(subdued) https://cmux.dev/docs\(reset)") + print(" \(bold)Docs\(reset)\(subdued) https://cmux.com/docs\(reset)") print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)") print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)") diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1571043e..23506926 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 */; }; @@ -86,7 +87,6 @@ D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; @@ -103,7 +103,22 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; - /* End PBXBuildFile section */ + E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; + 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; + 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; + 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; + CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; + 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; + 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; + B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; + DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */; }; + 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; + CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; + 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; + 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -217,6 +232,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 = ""; }; @@ -236,7 +252,6 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; @@ -253,6 +268,21 @@ DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; + 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; + 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; + 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; }; + BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; + 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; + BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; + B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; + D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; + 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; + 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; + 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; + EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; + 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; + 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -463,6 +493,7 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, + B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -475,7 +506,6 @@ F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { isa = PBXGroup; children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, @@ -489,6 +519,21 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, + 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, + 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, + 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, + BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, + 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, + BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, + B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, + D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, + 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, + 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, + EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, + 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -707,6 +752,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 */, @@ -719,7 +765,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, @@ -733,6 +778,21 @@ FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, + E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, + 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, + 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, + 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, + 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, + 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, + CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, + 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, + 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, + B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, + DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */, + 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, + CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, + 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, + 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/README.ar.md b/README.ar.md index 52785b7a..7edc241e 100644 --- a/README.ar.md +++ b/README.ar.md @@ -23,7 +23,7 @@

- ▶ فيديو توضيحي · فلسفة cmux + ▶ فيديو توضيحي · فلسفة cmux

## الميزات @@ -121,7 +121,7 @@ cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طر ## التوثيق -لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.dev/docs/getting-started?utm_source=readme). +لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.com/docs/getting-started?utm_source=readme). ## اختصارات لوحة المفاتيح diff --git a/README.bs.md b/README.bs.md index 5b16c652..b053f59f 100644 --- a/README.bs.md +++ b/README.bs.md @@ -23,7 +23,7 @@

- ▶ Demo video · The Zen of cmux + ▶ Demo video · The Zen of cmux

## Funkcije @@ -121,7 +121,7 @@ Dajte milion programera kompozabilne primitive i oni će kolektivno pronaći naj ## Dokumentacija -Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.dev/docs/getting-started?utm_source=readme). +Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.com/docs/getting-started?utm_source=readme). ## Prečice na Tastaturi diff --git a/README.da.md b/README.da.md index bf08e8e8..7d09bac4 100644 --- a/README.da.md +++ b/README.da.md @@ -23,7 +23,7 @@

- ▶ Demovideo · The Zen of cmux + ▶ Demovideo · The Zen of cmux

## Funktioner @@ -121,7 +121,7 @@ Giv en million udviklere komponerbare primitiver, og de vil kollektivt finde de ## Dokumentation -For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme). +For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.com/docs/getting-started?utm_source=readme). ## Tastaturgenveje diff --git a/README.de.md b/README.de.md index 2c3b3581..d7d68621 100644 --- a/README.de.md +++ b/README.de.md @@ -23,7 +23,7 @@

- ▶ Demo-Video · The Zen of cmux + ▶ Demo-Video · The Zen of cmux

## Funktionen @@ -121,7 +121,7 @@ Geben Sie einer Million Entwickler komponierbare Grundbausteine, und sie werden ## Dokumentation -Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme). +Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.com/docs/getting-started?utm_source=readme). ## Tastenkürzel diff --git a/README.es.md b/README.es.md index 01a6d051..cc492a14 100644 --- a/README.es.md +++ b/README.es.md @@ -23,7 +23,7 @@

- ▶ Video de demostración · The Zen of cmux + ▶ Video de demostración · The Zen of cmux

## Características @@ -121,7 +121,7 @@ Dale a un millón de desarrolladores primitivos componibles y encontrarán colec ## Documentación -Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.dev/docs/getting-started?utm_source=readme). +Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.com/docs/getting-started?utm_source=readme). ## Atajos de teclado diff --git a/README.fr.md b/README.fr.md index 462f6d9b..91e84e6c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -23,7 +23,7 @@

- ▶ Vidéo de démonstration · The Zen of cmux + ▶ Vidéo de démonstration · The Zen of cmux

## Fonctionnalités @@ -121,7 +121,7 @@ Donnez à un million de développeurs des primitives composables et ils trouvero ## Documentation -Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.dev/docs/getting-started?utm_source=readme). +Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.com/docs/getting-started?utm_source=readme). ## Raccourcis clavier diff --git a/README.it.md b/README.it.md index bb515256..46e30889 100644 --- a/README.it.md +++ b/README.it.md @@ -23,7 +23,7 @@

- ▶ Video demo · The Zen of cmux + ▶ Video demo · The Zen of cmux

## Funzionalità @@ -121,7 +121,7 @@ Date a un milione di sviluppatori primitive componibili e troveranno collettivam ## Documentazione -Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.dev/docs/getting-started?utm_source=readme). +Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.com/docs/getting-started?utm_source=readme). ## Scorciatoie da Tastiera diff --git a/README.ja.md b/README.ja.md index 074cdd91..dd1fd226 100644 --- a/README.ja.md +++ b/README.ja.md @@ -23,7 +23,7 @@

- ▶ デモ動画 · The Zen of cmux + ▶ デモ動画 · The Zen of cmux

## 機能 @@ -121,7 +121,7 @@ cmuxはソリューションではなくプリミティブです。ターミナ ## ドキュメント -cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.dev/docs/getting-started?utm_source=readme)。 +cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.com/docs/getting-started?utm_source=readme)。 ## キーボードショートカット diff --git a/README.km.md b/README.km.md index 19d3be94..7a8d1e89 100644 --- a/README.km.md +++ b/README.km.md @@ -23,7 +23,7 @@

- ▶ វីដេអូបង្ហាញពីដំណើរការ (Demo) · ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux) + ▶ វីដេអូបង្ហាញពីដំណើរការ (Demo) · ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)

## លក្ខណៈពិសេសនានា (Features) @@ -121,7 +121,7 @@ cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិន ## ឯកសារ (Documentation) -សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.dev/docs/getting-started?utm_source=readme)។ +សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.com/docs/getting-started?utm_source=readme)។ ## គ្រាប់ចុចផ្លូវកាត់ (Keyboard Shortcuts) diff --git a/README.ko.md b/README.ko.md index 8d92b94b..092a98c9 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,7 +23,7 @@

- ▶ 데모 영상 · The Zen of cmux + ▶ 데모 영상 · The Zen of cmux

## 기능 @@ -121,7 +121,7 @@ cmux는 솔루션이 아니라 프리미티브예요. 터미널, 브라우저, ## 문서 -cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.dev/docs/getting-started?utm_source=readme). +cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.com/docs/getting-started?utm_source=readme). ## 키보드 단축키 diff --git a/README.md b/README.md index 91c6ccbb..2566840f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

- ▶ Demo video · The Zen of cmux + ▶ Demo video · The Zen of cmux

## Features @@ -119,7 +119,7 @@ Give a million developers composable primitives and they'll collectively find th ## Documentation -For more info on how to configure cmux, [head over to our docs](https://cmux.dev/docs/getting-started?utm_source=readme). +For more info on how to configure cmux, [head over to our docs](https://cmux.com/docs/getting-started?utm_source=readme). ## Keyboard Shortcuts diff --git a/README.no.md b/README.no.md index e20ffb3e..2af7e1fc 100644 --- a/README.no.md +++ b/README.no.md @@ -23,7 +23,7 @@

- ▶ Demovideo · The Zen of cmux + ▶ Demovideo · The Zen of cmux

## Funksjoner @@ -121,7 +121,7 @@ Gi en million utviklere komponerbare primitiver og de vil kollektivt finne de me ## Dokumentasjon -For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.dev/docs/getting-started?utm_source=readme). +For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.com/docs/getting-started?utm_source=readme). ## Tastatursnarveier diff --git a/README.pl.md b/README.pl.md index d159935c..5df81ec3 100644 --- a/README.pl.md +++ b/README.pl.md @@ -23,7 +23,7 @@

- ▶ Film demonstracyjny · The Zen of cmux + ▶ Film demonstracyjny · The Zen of cmux

## Funkcje @@ -121,7 +121,7 @@ Daj milionowi programistów kompozycyjne prymitywy, a wspólnie znajdą najefekt ## Dokumentacja -Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.dev/docs/getting-started?utm_source=readme). +Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.com/docs/getting-started?utm_source=readme). ## Skróty Klawiszowe diff --git a/README.pt-BR.md b/README.pt-BR.md index c224d56c..71d8ed4c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -23,7 +23,7 @@

- ▶ Vídeo de demonstração · O Zen do cmux + ▶ Vídeo de demonstração · O Zen do cmux

## Recursos @@ -121,7 +121,7 @@ Dê a um milhão de desenvolvedores primitivas combináveis e eles coletivamente ## Documentação -Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.dev/docs/getting-started?utm_source=readme). +Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.com/docs/getting-started?utm_source=readme). ## Atalhos de Teclado diff --git a/README.ru.md b/README.ru.md index 6aea7f08..c68601ba 100644 --- a/README.ru.md +++ b/README.ru.md @@ -23,7 +23,7 @@

- ▶ Демо-видео · The Zen of cmux + ▶ Демо-видео · The Zen of cmux

## Возможности @@ -121,7 +121,7 @@ cmux — это примитив, а не решение. Он даёт вам ## Документация -Подробнее о настройке cmux читайте в [нашей документации](https://cmux.dev/docs/getting-started?utm_source=readme). +Подробнее о настройке cmux читайте в [нашей документации](https://cmux.com/docs/getting-started?utm_source=readme). ## Сочетания Клавиш diff --git a/README.th.md b/README.th.md index 60bd0451..9d1bf9ad 100644 --- a/README.th.md +++ b/README.th.md @@ -23,7 +23,7 @@

- ▶ วิดีโอสาธิต · The Zen of cmux + ▶ วิดีโอสาธิต · The Zen of cmux

## คุณสมบัติ @@ -121,7 +121,7 @@ cmux เป็นส่วนประกอบพื้นฐาน ไม่ ## เอกสารประกอบ -สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.dev/docs/getting-started?utm_source=readme) +สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.com/docs/getting-started?utm_source=readme) ## ปุ่มลัด diff --git a/README.tr.md b/README.tr.md index f07348f9..af8ba9a1 100644 --- a/README.tr.md +++ b/README.tr.md @@ -23,7 +23,7 @@

- ▶ Demo videosu · The Zen of cmux + ▶ Demo videosu · The Zen of cmux

## Özellikler @@ -121,7 +121,7 @@ Bir milyon geliştiriciye birleştirilebilir ilkel yapılar verin, en verimli i ## Dokümantasyon -cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.dev/docs/getting-started?utm_source=readme). +cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.com/docs/getting-started?utm_source=readme). ## Klavye Kısayolları diff --git a/README.vi.md b/README.vi.md index ba04b133..c3f2eb62 100644 --- a/README.vi.md +++ b/README.vi.md @@ -21,7 +21,7 @@

- ▶ Video demo · Thiền của cmux + ▶ Video demo · Thiền của cmux

## Tính năng @@ -119,7 +119,7 @@ Trao cho một triệu developer những nguyên thủy có thể ghép, và h ## Tài liệu -Để biết thêm về cách cấu hình cmux, [xem tài liệu của chúng tôi](https://cmux.dev/docs/getting-started?utm_source=readme). +Để biết thêm về cách cấu hình cmux, [xem tài liệu của chúng tôi](https://cmux.com/docs/getting-started?utm_source=readme). ## Phím tắt diff --git a/README.zh-CN.md b/README.zh-CN.md index 162e21a9..e45d71d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@

- ▶ 演示视频 · The Zen of cmux + ▶ 演示视频 · The Zen of cmux

## 功能特性 @@ -121,7 +121,7 @@ cmux 是原语,而非解决方案。它提供终端、浏览器、通知、工 ## 文档 -有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.dev/docs/getting-started?utm_source=readme)。 +有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.com/docs/getting-started?utm_source=readme)。 ## 键盘快捷键 diff --git a/README.zh-TW.md b/README.zh-TW.md index eca022d7..4f84d175 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -23,7 +23,7 @@

- ▶ 示範影片 · The Zen of cmux + ▶ 示範影片 · The Zen of cmux

## 功能特色 @@ -121,7 +121,7 @@ cmux 是一個基礎元件,而非完整方案。它提供終端機、瀏覽器 ## 文件 -如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.dev/docs/getting-started?utm_source=readme)。 +如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.com/docs/getting-started?utm_source=readme)。 ## 鍵盤快捷鍵 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3a5fb1bc..2cdbf7ca 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -38278,13 +38278,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Import From Browser…" + "value": "Import Browser Data…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザーから取り込む…" + "value": "ブラウザーデータを取り込む…" } } } @@ -43256,13 +43256,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Closing Last Surface Closes Workspace" + "value": "Keep Workspace Open When Closing Last Surface" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最後のサーフェスを閉じるとワークスペースも閉じる" + "value": "最後のサーフェスを閉じてもワークスペースを残す" } } } @@ -43273,13 +43273,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly." + "value": "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." } }, "ja": { "stringUnit": { "state": "translated", - "value": "最後のサーフェスを閉じてもワークスペースは残ります。ワークスペースを明示的に閉じるにはCmd+Shift+Wを使います。" + "value": "フォーカス中のサーフェスがそのワークスペースの最後の1つなら、サーフェスを閉じるショートカットはワークスペースも閉じます。" } } } @@ -43290,13 +43290,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Closing the last surface also closes its workspace." + "value": "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." } }, "ja": { "stringUnit": { "state": "translated", - "value": "最後のサーフェスを閉じると、そのワークスペースも閉じます。" + "value": "フォーカス中のサーフェスがそのワークスペースの最後の1つでも、サーフェスを閉じるショートカットはサーフェスだけを閉じ、ワークスペースは残します。ワークスペースを閉じるショートカットを使うと明示的に閉じられます。" } } } @@ -51377,13 +51377,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Import From Browser" + "value": "Import Browser Data" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザーから取り込む" + "value": "ブラウザーデータを取り込む" } } } diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 46afa112..1981b9f2 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -194,8 +194,6 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_PANEL_ID" ]] || return 0 local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt="" - local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 - local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 local -a gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then @@ -211,7 +209,7 @@ _cmux_report_pr_for_path() { [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ - && gh pr view \ + && gh pr view "$branch" \ "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ @@ -223,53 +221,20 @@ _cmux_report_pr_for_path() { /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then - : - else + if (( gh_status != 0 )) || [[ -z "$gh_output" ]]; then if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then - implicit_probe_indicates_no_pr=1 - elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - implicit_probe_indicates_no_pr=1 + _cmux_clear_pr_for_panel + return 0 + fi + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 fi - # `gh pr view` without an explicit branch can fail to resolve the - # current worktree branch even when the branch has a PR. Fall back to - # the explicit branch name before concluding there is no PR. - err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" - [[ -n "$err_file" ]] || return 1 - explicit_branch_output="$( - builtin cd "$repo_path" 2>/dev/null \ - && gh pr view "$branch" \ - "${gh_repo_args[@]}" \ - --json number,state,url \ - --jq '[.number, .state, .url] | @tsv' \ - 2>"$err_file" - )" - explicit_branch_status=$? - if [[ -f "$err_file" ]]; then - explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" - /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true - fi - - if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then - gh_output="$explicit_branch_output" - gh_status=0 - else - if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then - explicit_probe_indicates_no_pr=1 - elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then - explicit_probe_indicates_no_pr=1 - fi - - if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then - _cmux_clear_pr_for_panel - return 0 - fi - - # Preserve the last-known PR badge when gh fails transiently, then retry - # on the next background poll instead of clearing visible state. - return 1 - fi + # Always scope PR detection to the exact current branch. Preserve the + # last-known PR badge when gh fails transiently, then retry on the next + # background poll instead of showing a mismatched PR. + return 1 fi IFS=$'\t' read -r number state url <<< "$gh_output" @@ -284,7 +249,8 @@ _cmux_report_pr_for_path() { *) return 1 ;; esac - _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + local quoted_branch="${branch//\"/\\\"}" + _cmux_send "report_pr $number $url $status_opt --branch=\"$quoted_branch\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } _cmux_child_pids() { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 1bcf084f..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 } @@ -312,8 +311,6 @@ _cmux_report_pr_for_path() { [[ -n "$CMUX_PANEL_ID" ]] || return 0 local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status - local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0 - local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0 local -a gh_repo_args gh_repo_args=() branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" @@ -330,7 +327,7 @@ _cmux_report_pr_for_path() { [[ -n "$err_file" ]] || return 1 gh_output="$( builtin cd "$repo_path" 2>/dev/null \ - && gh pr view \ + && gh pr view "$branch" \ "${gh_repo_args[@]}" \ --json number,state,url \ --jq '[.number, .state, .url] | @tsv' \ @@ -342,53 +339,20 @@ _cmux_report_pr_for_path() { /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true fi - if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then - : - else + if (( gh_status != 0 )) || [[ -z "$gh_output" ]]; then if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then - implicit_probe_indicates_no_pr=1 - elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then - implicit_probe_indicates_no_pr=1 + _cmux_clear_pr_for_panel + return 0 + fi + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 fi - # `gh pr view` without an explicit branch can fail to resolve the - # current worktree branch even when the branch has a PR. Fall back to - # the explicit branch name before concluding there is no PR. - err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" - [[ -n "$err_file" ]] || return 1 - explicit_branch_output="$( - builtin cd "$repo_path" 2>/dev/null \ - && gh pr view "$branch" \ - "${gh_repo_args[@]}" \ - --json number,state,url \ - --jq '[.number, .state, .url] | @tsv' \ - 2>"$err_file" - )" - explicit_branch_status=$? - if [[ -f "$err_file" ]]; then - explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" - /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true - fi - - if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then - gh_output="$explicit_branch_output" - gh_status=0 - else - if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then - explicit_probe_indicates_no_pr=1 - elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then - explicit_probe_indicates_no_pr=1 - fi - - if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then - _cmux_clear_pr_for_panel - return 0 - fi - - # Keep the last-known PR badge on transient gh failures (auth hiccups, - # API lag after creation, or rate limiting) and retry on the next poll. - return 1 - fi + # Always scope PR detection to the exact current branch. When gh fails + # transiently (auth hiccups, API lag, rate limiting), keep the last-known + # badge and retry on the next poll instead of showing a mismatched PR. + return 1 fi local IFS=$'\t' @@ -404,7 +368,8 @@ _cmux_report_pr_for_path() { *) return 1 ;; esac - _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + local quoted_branch="${branch//\"/\\\"}" + _cmux_send "report_pr $number $url $status_opt --branch=\"$quoted_branch\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } _cmux_child_pids() { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e76c2327..d3160cb7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2069,6 +2069,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? 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] = [] @@ -2369,6 +2381,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") } @@ -2406,6 +2419,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) @@ -2414,6 +2429,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) @@ -2426,6 +2451,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) { @@ -2493,6 +2672,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setupGotoSplitUITestIfNeeded() setupBonsplitTabDragUITestIfNeeded() 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 @@ -2509,11 +2689,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 68a47af9..35d0fd95 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1320,6 +1320,40 @@ enum WorkspaceMountPolicy { } } +struct MountedWorkspacePresentation: Equatable { + let isRenderedVisible: Bool + let isPanelVisible: Bool + let renderOpacity: Double +} + +enum MountedWorkspacePresentationPolicy { + static func resolve( + isSelectedWorkspace: Bool, + isRetiringWorkspace: Bool, + shouldPrimeInBackground: Bool + ) -> MountedWorkspacePresentation { + let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace + let renderOpacity: Double = { + if isRenderedVisible { + return 1 + } + if shouldPrimeInBackground { + // Keep the workspace mounted long enough to warm the terminal surface, but do + // not mark it panel-visible. Visible portal entries intentionally survive + // transient anchor loss during bonsplit drag/reparent churn. + return 0.001 + } + return 0 + }() + + return MountedWorkspacePresentation( + isRenderedVisible: isRenderedVisible, + isPanelVisible: isRenderedVisible, + renderOpacity: renderOpacity + ) + } +} + /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, @@ -1914,7 +1948,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 +1960,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 +1977,7 @@ struct ContentView: View { } .onEnded { _ in if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() isResizerDragging = false sidebarDragStartWidth = nil } @@ -2022,17 +2058,11 @@ struct ContentView: View { let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) - let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace - let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground - let workspaceRenderOpacity: Double = { - if isRenderedVisible { - return 1 - } - if shouldPrimeInBackground { - return 0.001 - } - return 0 - }() + let presentation = MountedWorkspacePresentationPolicy.resolve( + isSelectedWorkspace: isSelectedWorkspace, + isRetiringWorkspace: isRetiringWorkspace, + shouldPrimeInBackground: shouldPrimeInBackground + ) // Keep the retiring workspace visible during handoff, but never input-active. // Allowing both selected+retiring workspaces to be input-active lets the // old workspace steal first responder (notably with WKWebView), which can @@ -2041,7 +2071,7 @@ struct ContentView: View { let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, - isWorkspaceVisible: isWorkspaceVisibleToPanels, + isWorkspaceVisible: presentation.isPanelVisible, isWorkspaceInputActive: isInputActive, workspacePortalPriority: portalPriority, onThemeRefreshRequest: { reason, eventId, source, payloadHex in @@ -2054,9 +2084,9 @@ struct ContentView: View { ) } ) - .opacity(workspaceRenderOpacity) + .opacity(presentation.renderOpacity) .allowsHitTesting(isSelectedWorkspace) - .accessibilityHidden(!isRenderedVisible) + .accessibilityHidden(!presentation.isRenderedVisible) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) .task(id: shouldPrimeInBackground ? tab.id : nil) { await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) @@ -2725,12 +2755,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() }) @@ -2752,6 +2790,11 @@ struct ContentView: View { }) view = AnyView(view.onDisappear { + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + sidebarDragStartWidth = nil + } removeSidebarResizerPointerMonitor() }) @@ -3333,6 +3376,8 @@ struct ContentView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("CommandPaletteResultRow.\(index)") + .accessibilityValue(result.id) .id(index) .onHover { hovering in if hovering { @@ -7896,12 +7941,14 @@ struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendabl let payload: Payload let rank: Int let title: String + let normalizedTitle: String let normalizedSearchableTexts: [String] init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { self.payload = payload self.rank = rank self.title = title + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) self.normalizedSearchableTexts = searchableTexts .map(CommandPaletteFuzzyMatcher.normalizeForSearch) .filter { !$0.isEmpty } @@ -7917,6 +7964,8 @@ struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendab } enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + static func search( entries: [CommandPaletteSearchCorpusEntry], query: String, @@ -7976,9 +8025,9 @@ enum CommandPaletteSearchEngine { } else { for (index, entry) in entries.enumerated() { if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + guard let fuzzyScore = weightedScore( preparedQuery: preparedQuery, - normalizedCandidates: entry.normalizedSearchableTexts + entry: entry ) else { continue } @@ -8005,6 +8054,26 @@ enum CommandPaletteSearchEngine { return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending } } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + titleMatchBonus) + } } private struct SidebarResizerAccessibilityModifier: ViewModifier { @@ -8320,7 +8389,7 @@ enum DevBuildBannerDebugSettings { private enum FeedbackComposerSettings { static let storedEmailKey = "sidebarHelpFeedbackEmail" static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" - static let defaultEndpoint = "https://www.cmux.dev/api/feedback" + static let defaultEndpoint = "https://cmux.com/api/feedback" static let foundersEmail = "founders@manaflow.com" static let maxMessageLength = 4_000 static let maxAttachmentCount = 10 @@ -8384,6 +8453,11 @@ private struct FeedbackComposerAppMetadata { let bundleIdentifier: String let osVersion: String let localeIdentifier: String + let hardwareModel: String + let chip: String + let memoryGB: String + let architecture: String + let displayInfo: String static var current: FeedbackComposerAppMetadata { let infoDictionary = Bundle.main.infoDictionary ?? [:] @@ -8398,9 +8472,50 @@ private struct FeedbackComposerAppMetadata { appCommit: commit ?? "", bundleIdentifier: Bundle.main.bundleIdentifier ?? "", osVersion: ProcessInfo.processInfo.operatingSystemVersionString, - localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, + hardwareModel: sysctlString("hw.model") ?? "", + chip: sysctlString("machdep.cpu.brand_string") ?? "", + memoryGB: formatMemoryGB(), + architecture: currentArchitecture(), + displayInfo: currentDisplayInfo() ) } + + private static func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func formatMemoryGB() -> String { + let bytes = ProcessInfo.processInfo.physicalMemory + let gb = Double(bytes) / (1024 * 1024 * 1024) + return "\(Int(gb)) GB" + } + + private static func currentArchitecture() -> String { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif + } + + private static func currentDisplayInfo() -> String { + let screens = NSScreen.screens + let descriptions = screens.map { screen -> String in + let frame = screen.frame + let scale = screen.backingScaleFactor + return "\(Int(frame.width))x\(Int(frame.height)) @\(Int(scale))x" + } + let count = screens.count + let prefix = "\(count) display\(count == 1 ? "" : "s")" + return "\(prefix), \(descriptions.joined(separator: "; "))" + } } private enum FeedbackComposerSubmissionError: Error { @@ -8454,6 +8569,11 @@ private enum FeedbackComposerClient { appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + appendField("hardwareModel", value: metadata.hardwareModel, to: &body, boundary: boundary) + appendField("chip", value: metadata.chip, to: &body, boundary: boundary) + appendField("memoryGB", value: metadata.memoryGB, to: &body, boundary: boundary) + appendField("architecture", value: metadata.architecture, to: &body, boundary: boundary) + appendField("displayInfo", value: metadata.displayInfo, to: &body, boundary: boundary) for attachment in preparedAttachments { appendFile( @@ -9326,6 +9446,7 @@ private struct SidebarFeedbackComposerSheet: View { ) .font(.system(size: 12)) .foregroundStyle(.secondary) + .textSelection(.enabled) HStack { Spacer() @@ -9767,8 +9888,8 @@ enum FeedbackComposerBridge { } private struct SidebarHelpMenuButton: View { - private let docsURL = URL(string: "https://cmux.dev/docs") - private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") + private let docsURL = URL(string: "https://cmux.com/docs") + private let changelogURL = URL(string: "https://cmux.com/docs/changelog") private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues") private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") @@ -9836,7 +9957,7 @@ private struct SidebarHelpMenuButton: View { isExternalLink: false ) helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"), + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), action: .importBrowserData, accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", isExternalLink: false @@ -10909,7 +11030,7 @@ private struct TabItemView: View, Equatable { .underline() .lineLimit(1) .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status)) + Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) .lineLimit(1) Spacer(minLength: 0) } @@ -11605,6 +11726,7 @@ private struct TabItemView: View, Equatable { let label: String let url: URL let status: SidebarPullRequestStatus + let checks: SidebarPullRequestChecksStatus? } private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { @@ -11614,7 +11736,8 @@ private struct TabItemView: View, Equatable { number: pullRequest.number, label: pullRequest.label, url: pullRequest.url, - status: pullRequest.status + status: pullRequest.status, + checks: pullRequest.checks ) } } @@ -11639,7 +11762,10 @@ private struct TabItemView: View, Equatable { NSWorkspace.shared.open(url) } - private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { + private func pullRequestStatusLabel( + _ status: SidebarPullRequestStatus, + checks _: SidebarPullRequestChecksStatus? + ) -> String { switch status { case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3a96db71..c136afa4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5227,6 +5227,26 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } } + + if shouldSendCommittedIMEConfirmKey( + event: translationEvent, + markedTextBefore: markedTextBefore + ) { + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = nil +#if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.accumulatedConfirmGhosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 +#else + _ = ghostty_surface_key(surface, keyEvent) +#endif + } } else { // Get the appropriate text for this key event // For control characters, this returns the unmodified character @@ -5487,6 +5507,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } + private func shouldSendCommittedIMEConfirmKey(event: NSEvent, markedTextBefore: Bool) -> Bool { + guard markedTextBefore, markedText.length == 0 else { return false } + return event.keyCode == 36 || event.keyCode == 76 + } + private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS @@ -6599,6 +6624,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() @@ -8823,6 +8851,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 @@ -8830,14 +8866,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/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 6bffccb2..83f072a8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -965,7 +965,7 @@ struct BrowserPanelView: View { Button { presentImportDialogFromProfileMenu() } label: { - Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) + Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) .font(.system(size: 12)) } .buttonStyle(.plain) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index b0303d53..188833d4 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -377,9 +377,10 @@ enum SessionPersistenceStore { let directory = fileURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(snapshot) + let data = try encodedSnapshotData(snapshot) + if let existingData = try? Data(contentsOf: fileURL), existingData == data { + return true + } try data.write(to: fileURL, options: .atomic) return true } catch { @@ -387,6 +388,12 @@ enum SessionPersistenceStore { } } + private static func encodedSnapshotData(_ snapshot: AppSessionSnapshot) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(snapshot) + } + static func removeSnapshot(fileURL: URL? = nil) { guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } try? FileManager.default.removeItem(at: fileURL) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 97271038..6df6abaf 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -60,6 +60,20 @@ enum WorkspaceAutoReorderSettings { } } +enum LastSurfaceCloseShortcutSettings { + static let key = "closeWorkspaceOnLastSurfaceShortcut" + // Keep the legacy stored meaning so existing values still map to the same + // behavior. The default is flipped to preserve current Cmd+W behavior. + static let defaultValue = true + + static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultValue + } + return defaults.bool(forKey: key) + } +} + enum SidebarBranchLayoutSettings { static let key = "sidebarBranchVerticalLayout" static let defaultVerticalLayout = true @@ -633,10 +647,17 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { + private enum WorkspacePullRequestSnapshot: Equatable { + case unsupportedRepository + case notFound + case resolved(SidebarPullRequestState) + case transientFailure + } + private struct InitialWorkspaceGitMetadataSnapshot: Equatable { let branch: String? let isDirty: Bool - let pullRequest: SidebarPullRequestState? + let pullRequest: WorkspacePullRequestSnapshot } private struct CommandResult { @@ -647,6 +668,22 @@ class TabManager: ObservableObject { let executionError: String? } + private struct WorkspaceGitProbeKey: Hashable { + let workspaceId: UUID + let panelId: UUID + } + + private struct GitHubPullRequestViewItem: Decodable { + let number: Int + let state: String + let url: String + } + + private struct GitHubPullRequestCheckItem: Decodable { + let bucket: String? + let state: String? + } + /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? @@ -660,7 +697,7 @@ class TabManager: ObservableObject { /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] - private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0 + private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { #if DEBUG @@ -747,8 +784,8 @@ class TabManager: ObservableObject { label: "com.cmux.initial-workspace-git-probe", qos: .utility ) - private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:] - private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:] + private var workspaceGitProbeGenerationByKey: [WorkspaceGitProbeKey: UUID] = [:] + private var workspaceGitProbeTimersByKey: [WorkspaceGitProbeKey: [DispatchSourceTimer]] = [:] // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -878,6 +915,33 @@ class TabManager: ObservableObject { } } + private func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? { + let rawDirectory = workspace.panelDirectories[panelId] + ?? (workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil) + return rawDirectory.flatMap(normalizedWorkingDirectory) + } + + private func scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: UUID, + panelId: UUID, + reason: String, + delays: [TimeInterval] = [0] + ) { + guard let workspace = tabs.first(where: { $0.id == workspaceId }), + workspace.panels[panelId] != nil, + let directory = gitProbeDirectory(for: workspace, panelId: panelId) else { + return + } + + scheduleWorkspaceGitMetadataRefresh( + workspaceId: workspaceId, + panelId: panelId, + directory: directory, + delays: delays, + reason: reason + ) + } + private func wireClosedBrowserTracking(for workspace: Workspace) { workspace.onClosedBrowserPanel = { [weak self] snapshot in self?.recentlyClosedBrowsers.push(snapshot) @@ -1133,20 +1197,36 @@ class TabManager: ObservableObject { workspaceId: UUID, panelId: UUID, directory: String + ) { + scheduleWorkspaceGitMetadataRefresh( + workspaceId: workspaceId, + panelId: panelId, + directory: directory, + delays: Self.initialWorkspaceGitProbeDelays, + reason: "initial" + ) + } + + private func scheduleWorkspaceGitMetadataRefresh( + workspaceId: UUID, + panelId: UUID, + directory: String, + delays: [TimeInterval], + reason: String ) { let normalizedDirectory = normalizeDirectory(directory) + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) let generation = UUID() - cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) - initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation + cancelWorkspaceGitProbeTimers(for: key) + workspaceGitProbeGenerationByKey[key] = generation #if DEBUG dlog( "workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)" + "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory) reason=\(reason)" ) #endif - let delays = Self.initialWorkspaceGitProbeDelays var timers: [DispatchSourceTimer] = [] for (index, delay) in delays.enumerated() { let isLastAttempt = index == delays.count - 1 @@ -1155,11 +1235,10 @@ class TabManager: ObservableObject { timer.setEventHandler { [weak self] in let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) Task { @MainActor [weak self] in - self?.applyInitialWorkspaceGitMetadataSnapshot( + self?.applyWorkspaceGitMetadataSnapshot( snapshot, generation: generation, - workspaceId: workspaceId, - panelId: panelId, + probeKey: key, expectedDirectory: normalizedDirectory, isLastAttempt: isLastAttempt ) @@ -1168,11 +1247,11 @@ class TabManager: ObservableObject { timers.append(timer) timer.resume() } - initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers + workspaceGitProbeTimersByKey[key] = timers } - private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) { - guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else { + private func cancelWorkspaceGitProbeTimers(for key: WorkspaceGitProbeKey) { + guard let timers = workspaceGitProbeTimersByKey.removeValue(forKey: key) else { return } for timer in timers { @@ -1181,95 +1260,139 @@ class TabManager: ObservableObject { } } - private func clearInitialWorkspaceGitProbe(workspaceId: UUID) { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) - cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) + private func clearWorkspaceGitProbe(_ key: WorkspaceGitProbeKey) { + workspaceGitProbeGenerationByKey.removeValue(forKey: key) + cancelWorkspaceGitProbeTimers(for: key) } - private func applyInitialWorkspaceGitMetadataSnapshot( + private func clearWorkspaceGitProbes(workspaceId: UUID) { + let keys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId }) + .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) + for key in keys { + clearWorkspaceGitProbe(key) + } + } + + private func applyWorkspaceGitMetadataSnapshot( _ snapshot: InitialWorkspaceGitMetadataSnapshot, generation: UUID, - workspaceId: UUID, - panelId: UUID, + probeKey: WorkspaceGitProbeKey, expectedDirectory: String, isLastAttempt: Bool ) { defer { - if isLastAttempt, - initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + if shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt, + workspaceGitProbeGenerationByKey[probeKey] == generation { + clearWorkspaceGitProbe(probeKey) } } - guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return } - guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + guard workspaceGitProbeGenerationByKey[probeKey] == generation else { return } + guard let workspace = tabs.first(where: { $0.id == probeKey.workspaceId }) else { + clearWorkspaceGitProbe(probeKey) return } - guard workspace.panels[panelId] != nil else { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + guard workspace.panels[probeKey.panelId] != nil else { + clearWorkspaceGitProbe(probeKey) return } - let currentDirectory = normalizedWorkingDirectory( - workspace.panelDirectories[panelId] ?? workspace.currentDirectory - ) - if let currentDirectory, currentDirectory != expectedDirectory { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + guard let currentDirectory = gitProbeDirectory(for: workspace, panelId: probeKey.panelId) else { + clearWorkspaceGitProbe(probeKey) + return + } + if currentDirectory != expectedDirectory { + clearWorkspaceGitProbe(probeKey) #if DEBUG dlog( - "workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " + + "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + + "panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " + "expected=\(expectedDirectory) current=\(currentDirectory)" ) #endif return } - workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory) + workspace.updatePanelDirectory(panelId: probeKey.panelId, directory: expectedDirectory) - let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch) let nextBranch = snapshot.branch if let nextBranch { - workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty) + workspace.updatePanelGitBranch( + panelId: probeKey.panelId, + branch: nextBranch, + isDirty: snapshot.isDirty + ) } else { - workspace.clearPanelGitBranch(panelId: panelId) + workspace.clearPanelGitBranch(panelId: probeKey.panelId) } - if let pullRequest = snapshot.pullRequest { + switch snapshot.pullRequest { + case .resolved(let pullRequest): workspace.updatePanelPullRequest( - panelId: panelId, + panelId: probeKey.panelId, number: pullRequest.number, label: pullRequest.label, url: pullRequest.url, - status: pullRequest.status + status: pullRequest.status, + checks: pullRequest.checks ) - } else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { - workspace.clearPanelPullRequest(panelId: panelId) + case .notFound: + if workspace.panelPullRequests[probeKey.panelId] != nil { + workspace.clearPanelPullRequest(panelId: probeKey.panelId) + } + case .unsupportedRepository, .transientFailure: + break } #if DEBUG let branchLabel = snapshot.branch ?? "none" - let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none" + let prLabel: String = { + switch snapshot.pullRequest { + case .unsupportedRepository: + return "unsupported" + case .notFound: + return "none" + case .transientFailure: + return "transientFailure" + case .resolved(let pullRequest): + let checks = pullRequest.checks?.rawValue ?? "none" + return "#\(pullRequest.number):\(pullRequest.status.rawValue):\(checks)" + } + }() dlog( - "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + + "workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + + "panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + "pr=\(prLabel)" ) #endif } + private func shouldStopWorkspaceGitMetadataRefresh( + _ snapshot: InitialWorkspaceGitMetadataSnapshot + ) -> Bool { + switch snapshot.pullRequest { + case .transientFailure: + return false + case .unsupportedRepository, .notFound, .resolved: + return true + } + } + private nonisolated static func initialWorkspaceGitMetadataSnapshot( for directory: String ) -> InitialWorkspaceGitMetadataSnapshot { let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) guard let branch else { - return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil) + return InitialWorkspaceGitMetadataSnapshot( + branch: nil, + isDirty: false, + pullRequest: .notFound + ) } let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch) + let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch) return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) } @@ -1281,34 +1404,42 @@ class TabManager: ObservableObject { ) } - private nonisolated static func initialWorkspacePullRequestSnapshot( + private nonisolated static func workspacePullRequestSnapshot( directory: String, branch: String - ) -> SidebarPullRequestState? { - let repoSlug = githubRepositorySlug(directory: directory) - let repoArguments = repoSlug.map { ["--repo", $0] } ?? [] + ) -> WorkspacePullRequestSnapshot { + guard let repoSlug = githubRepositorySlug(directory: directory) else { + return .unsupportedRepository + } + let result = runCommandResult( directory: directory, executable: "gh", arguments: [ "pr", "view", branch, - ] + repoArguments + [ + "--repo", repoSlug, "--json", "number,state,url", - "--jq", "[.number, .state, .url] | @tsv", ], - timeout: initialWorkspacePullRequestProbeTimeout + timeout: workspacePullRequestProbeTimeout ) - guard let result else { return nil } - guard let output = result.stdout, - result.exitStatus == 0, - !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let result else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) status=nil" + ) +#endif + return .transientFailure + } + + guard !result.timedOut, + result.executionError == nil, + let exitStatus = result.exitStatus else { #if DEBUG let statusText: String if result.timedOut { statusText = "timeout" - } else if let exitStatus = result.exitStatus { - statusText = "exit=\(exitStatus)" } else if let executionError = result.executionError { statusText = "error=\(executionError)" } else { @@ -1317,47 +1448,188 @@ class TabManager: ObservableObject { let stderr = debugLogSnippet(result.stderr) ?? "none" dlog( "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)" + "repo=\(repoSlug) status=\(statusText) stderr=\(stderr)" ) #endif - return nil + return .transientFailure } - let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - let fields = trimmedOutput - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false) - guard fields.count == 3, - let number = Int(fields[0]), - let url = URL(string: String(fields[2])) else { + if exitStatus != 0 { + let stderr = result.stderr ?? "" + if prErrorIndicatesNoPullRequest(stderr) { +#if DEBUG + dlog( + "workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) stderr=\(debugLogSnippet(stderr) ?? "none")" + ) +#endif + return .notFound + } +#if DEBUG + dlog( + "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(stderr) ?? "none")" + ) +#endif + return .transientFailure + } + + let output = result.stdout ?? "" + guard !output.isEmpty, + let pullRequest = decodeJSON(GitHubPullRequestViewItem.self, from: output) else { #if DEBUG dlog( "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")" + "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" ) #endif - return nil + return .transientFailure } - let status: SidebarPullRequestStatus - switch fields[1].uppercased() { - case "OPEN": - status = .open - case "MERGED": - status = .merged - case "CLOSED": - status = .closed - default: - return nil + guard let status = pullRequestStatus(from: pullRequest.state), + let url = URL(string: pullRequest.url) else { +#if DEBUG + dlog( + "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + + "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" + ) +#endif + return .transientFailure } + let checks = status == .open + ? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug) + : nil + #if DEBUG dlog( "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)" + "repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")" ) #endif - return SidebarPullRequestState(number: number, label: "PR", url: url, status: status) + return .resolved( + SidebarPullRequestState( + number: pullRequest.number, + label: "PR", + url: url, + status: status, + branch: branch, + checks: checks + ) + ) + } + + private nonisolated static func pullRequestChecksStatus( + number: Int, + directory: String, + repoSlug: String + ) -> SidebarPullRequestChecksStatus? { + let result = runCommandResult( + directory: directory, + executable: "gh", + arguments: [ + "pr", "checks", String(number), + "--repo", repoSlug, + "--json", "bucket,state" + ], + timeout: workspacePullRequestProbeTimeout + ) + + guard let result, + !result.timedOut, + result.executionError == nil, + let output = result.stdout, + let exitStatus = result.exitStatus, + exitStatus == 0 || exitStatus == 8, + let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else { + return nil + } + + var sawPending = false + var sawPass = false + + for check in checks { + let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if isFailingCheckState(bucket: bucket, state: state) { + return .fail + } + if isPendingCheckState(bucket: bucket, state: state) { + sawPending = true + continue + } + if isPassingCheckState(bucket: bucket, state: state) { + sawPass = true + } + } + + if sawPending { + return .pending + } + if sawPass { + return .pass + } + return nil + } + + private nonisolated static func pullRequestStatus( + from rawState: String + ) -> SidebarPullRequestStatus? { + switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "OPEN": + return .open + case "MERGED": + return .merged + case "CLOSED": + return .closed + default: + return nil + } + } + + private nonisolated static func decodeJSON(_ type: T.Type, from text: String) -> T? { + guard let data = text.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private nonisolated static func prErrorIndicatesNoPullRequest(_ text: String?) -> Bool { + let normalized = text? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !normalized.isEmpty else { return false } + return normalized.contains("no pull requests found") + || normalized.contains("no pull request found") + || normalized.contains("no pull requests associated") + || normalized.contains("no pull request associated") + } + + private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "fail", "failure", "failed", "error", "timed_out", "timedout", + "cancel", "cancelled", "canceled", "action_required", "startup_failure": + return true + default: + return false + } + } + + private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "pending", "queued", "in_progress", "requested", "waiting", "expected": + return true + default: + return false + } + } + + private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool { + switch bucket ?? state ?? "" { + case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped": + return true + default: + return false + } } private nonisolated static func runCommand( @@ -1751,8 +2023,49 @@ class TabManager: ObservableObject { func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + let previousDirectory = gitProbeDirectory(for: tab, panelId: surfaceId) let normalized = normalizeDirectory(directory) tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) + let nextDirectory = normalizedWorkingDirectory(normalized) + if previousDirectory != nextDirectory { + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: tabId, + panelId: surfaceId, + reason: "directoryChange" + ) + } + } + + func updateSurfaceGitBranch( + tabId: UUID, + surfaceId: UUID, + branch: String, + isDirty: Bool + ) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + let current = tab.panelGitBranches[surfaceId] + let normalizedBranch = Self.normalizedBranchName(branch) ?? branch + guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return } + tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty) + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: tabId, + panelId: surfaceId, + reason: "branchChange" + ) + } + + func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + let hadBranch = tab.panelGitBranches[surfaceId] != nil + let hadPullRequest = tab.panelPullRequests[surfaceId] != nil + guard hadBranch || hadPullRequest else { return } + tab.clearPanelGitBranch(panelId: surfaceId) + tab.clearPanelPullRequest(panelId: surfaceId) + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: tabId, + panelId: surfaceId, + reason: "branchCleared" + ) } func updateSurfaceShellActivity( @@ -1778,7 +2091,7 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - clearInitialWorkspaceGitProbe(workspaceId: workspace.id) + clearWorkspaceGitProbes(workspaceId: workspace.id) sidebarSelectedWorkspaceIds.remove(workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) @@ -1805,7 +2118,7 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - clearInitialWorkspaceGitProbe(workspaceId: tabId) + clearWorkspaceGitProbes(workspaceId: tabId) sidebarSelectedWorkspaceIds.remove(tabId) let removed = tabs.remove(at: index) @@ -2101,6 +2414,12 @@ class TabManager: ObservableObject { } } + private func shouldCloseWorkspaceOnLastSurfaceShortcut(_ workspace: Workspace, panelId: UUID) -> Bool { + LastSurfaceCloseShortcutSettings.closesWorkspace() && + workspace.panels.count <= 1 && + workspace.panels[panelId] != nil + } + private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { guard tab.panels[panelId] != nil else { #if DEBUG @@ -2121,18 +2440,20 @@ class TabManager: ObservableObject { if panel is BrowserPanel { return "browser" } return String(describing: type(of: panel)) }() + let closesWorkspaceOnLastSurfaceShortcut = shouldCloseWorkspaceOnLastSurfaceShortcut(tab, panelId: panelId) #if DEBUG dlog( "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + - "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)" + "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " + + "closeWorkspaceOnLastSurface=\(closesWorkspaceOnLastSurfaceShortcut ? 1 : 0)" ) #endif - // Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close - // button, including shared confirmation, last-surface workspace/window-close behavior, - // and the usual replacement-panel flow when the close does not collapse the workspace. - if let surfaceId = tab.surfaceIdFromPanelId(panelId) { + // The last-surface shortcut preference only affects Cmd+W. The tab close button + // continues to use Workspace's explicit-close path when it closes the last surface. + if closesWorkspaceOnLastSurfaceShortcut, + let surfaceId = tab.surfaceIdFromPanelId(panelId) { tab.markExplicitClose(surfaceId: surfaceId) } let closed = tab.closePanel(panelId) @@ -4585,8 +4906,10 @@ extension TabManager { for tab in tabs { unwireClosedBrowserTracking(for: tab) } - for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) + let existingProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + .union(workspaceGitProbeTimersByKey.keys) + for key in existingProbeKeys { + clearWorkspaceGitProbe(key) } // Clear non-@Published state without touching tabs/selectedTabId yet. @@ -4645,19 +4968,17 @@ extension TabManager { tabs = newTabs selectedTabId = newSelectedId for workspace in newTabs { - guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values - .compactMap({ $0 as? TerminalPanel }) - .first, - let directory = normalizedWorkingDirectory( - workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory - ) else { - continue + let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel } + for terminalPanel in terminalPanels { + guard let directory = gitProbeDirectory(for: workspace, panelId: terminalPanel.id) else { + continue + } + scheduleInitialWorkspaceGitMetadataRefresh( + workspaceId: workspace.id, + panelId: terminalPanel.id, + directory: directory + ) } - scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: workspace.id, - panelId: terminalPanel.id, - directory: directory - ) } if let selectedTabId { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index de1e96f3..6227cbaf 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -387,10 +387,42 @@ class TerminalController { number: Int, label: String, url: URL, - status: SidebarPullRequestStatus + status: SidebarPullRequestStatus, + branch: String?, + checks: SidebarPullRequestChecksStatus? ) -> Bool { guard let current else { return true } - return current.number != number || current.label != label || current.url != url || current.status != status + let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveBranch: String? = { + if let normalizedBranch, !normalizedBranch.isEmpty { + return normalizedBranch + } + guard current.number == number, + current.label == label, + current.url == url, + current.status == status else { + return nil + } + return current.branch + }() + let effectiveChecks: SidebarPullRequestChecksStatus? = { + if let checks { + return checks + } + guard current.number == number, + current.label == label, + current.url == url, + current.status == status else { + return nil + } + return current.checks + }() + return current.number != number + || current.label != label + || current.url != url + || current.status != status + || current.branch != effectiveBranch + || current.checks != effectiveChecks } nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { @@ -10844,8 +10876,8 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch - report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item - report_review [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item + report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Report pull request / review item + report_review [--label=MR] [--state=open|merged|closed] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Alias for provider-specific review item clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning @@ -14492,7 +14524,12 @@ class TerminalController { let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } - tab.updatePanelGitBranch(panelId: scope.panelId, branch: branch, isDirty: isDirty) + tabManager.updateSurfaceGitBranch( + tabId: scope.workspaceId, + surfaceId: scope.panelId, + branch: branch, + isDirty: isDirty + ) } return "OK" } @@ -14523,7 +14560,7 @@ class TerminalController { let validSurfaceIds = Set(tab.panels.keys) tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) guard validSurfaceIds.contains(scope.panelId) else { return } - tab.clearPanelGitBranch(panelId: scope.panelId) + tabManager.clearSurfaceGitBranch(tabId: scope.workspaceId, surfaceId: scope.panelId) } return "OK" } @@ -14541,7 +14578,7 @@ class TerminalController { private func reportPullRequest(_ args: String) -> String { let parsed = parseOptions(args) guard parsed.positional.count >= 2 else { - return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" } let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) @@ -14561,10 +14598,21 @@ class TerminalController { guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else { return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" } + let branch = normalizedOptionValue(parsed.options["branch"]) + + let checks: SidebarPullRequestChecksStatus? + if let rawChecks = normalizedOptionValue(parsed.options["checks"]) { + guard let parsedChecks = SidebarPullRequestChecksStatus(rawValue: rawChecks.lowercased()) else { + return "ERROR: Invalid pull request checks '\(rawChecks)' — use: pass, fail, pending" + } + checks = parsedChecks + } else { + checks = nil + } let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" guard !labelRaw.isEmpty else { - return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" } let label = String(labelRaw.prefix(16)) @@ -14573,14 +14621,16 @@ class TerminalController { return schedulePanelMetadataMutation( args: args, options: parsed.options, - missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + missingPanelUsage: "report_pr [--label=PR] [--state=open|merged|closed] [--branch=] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" ) { tab, surfaceId in guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], number: number, label: label, url: url, - status: status + status: status, + branch: branch, + checks: checks ) else { return } @@ -14590,7 +14640,9 @@ class TerminalController { number: number, label: label, url: url, - status: status + status: status, + branch: branch, + checks: checks ) } } @@ -14958,12 +15010,14 @@ class TerminalController { lines.append("git_branch=none") } - if let pr = tab.pullRequest { + if let pr = tab.sidebarPullRequestsInDisplayOrder().first { lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") lines.append("pr_label=\(pr.label)") + lines.append("pr_checks=\(pr.checks?.rawValue ?? "none")") } else { lines.append("pr=none") lines.append("pr_label=none") + lines.append("pr_checks=none") } if tab.listeningPorts.isEmpty { 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/Workspace.swift b/Sources/Workspace.swift index d40006d0..4557f122 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4506,11 +4506,41 @@ enum SidebarPullRequestStatus: String { case closed } +enum SidebarPullRequestChecksStatus: String { + case pass + case fail + case pending +} + +private func normalizedSidebarBranchName(_ branch: String?) -> String? { + guard let branch else { return nil } + let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} + struct SidebarPullRequestState: Equatable { let number: Int let label: String let url: URL let status: SidebarPullRequestStatus + let branch: String? + let checks: SidebarPullRequestChecksStatus? + + init( + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) { + self.number = number + self.label = label + self.url = url + self.status = status + self.branch = normalizedSidebarBranchName(branch) + self.checks = checks + } } enum SidebarBranchOrdering { @@ -4606,6 +4636,15 @@ enum SidebarBranchOrdering { } } + func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { + switch checks { + case .fail: return 3 + case .pending: return 2 + case .pass: return 1 + case nil: return 0 + } + } + func normalizedReviewURLKey(for url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString @@ -4642,6 +4681,9 @@ enum SidebarBranchOrdering { guard let existing = pullRequestsByKey[key] else { continue } if statusPriority(state.status) > statusPriority(existing.status) { pullRequestsByKey[key] = state + } else if state.status == existing.status, + checksPriority(state.checks) > checksPriority(existing.checks) { + pullRequestsByKey[key] = state } } @@ -5164,8 +5206,10 @@ final class Workspace: Identifiable, ObservableObject { /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. private var pendingCloseConfirmTabIds: Set = [] - /// Tab IDs whose next close attempt came from an explicit user close gesture - /// (Cmd+W or the tab-strip X button), rather than an internal close/move flow. + /// Tab IDs whose next close attempt should be treated as an explicit + /// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when + /// the shortcut preference is set to close the workspace on the last surface), + /// rather than an internal close/move flow. private var explicitUserCloseTabIds: Set = [] /// Deterministic tab selection to apply after a tab closes. @@ -5669,9 +5713,16 @@ final class Workspace: Identifiable, ObservableObject { func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) let existing = panelGitBranches[panelId] + let branchChanged = existing?.branch != nil && existing?.branch != branch if existing?.branch != branch || existing?.isDirty != isDirty { panelGitBranches[panelId] = state } + if branchChanged { + panelPullRequests.removeValue(forKey: panelId) + if panelId == focusedPanelId { + pullRequest = nil + } + } if panelId == focusedPanelId { gitBranch = state } @@ -5679,8 +5730,10 @@ final class Workspace: Identifiable, ObservableObject { func clearPanelGitBranch(panelId: UUID) { panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) if panelId == focusedPanelId { gitBranch = nil + pullRequest = nil } } @@ -5689,10 +5742,50 @@ final class Workspace: Identifiable, ObservableObject { number: Int, label: String, url: URL, - status: SidebarPullRequestStatus + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil ) { - let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) let existing = panelPullRequests[panelId] + let normalizedBranch = normalizedSidebarBranchName(branch) + let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) + let resolvedBranch: String? = { + if let normalizedBranch { + return normalizedBranch + } + if let currentPanelBranch { + return currentPanelBranch + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.branch + }() + let resolvedChecks: SidebarPullRequestChecksStatus? = { + if let checks { + return checks + } + guard let existing, + existing.number == number, + existing.label == label, + existing.url == url, + existing.status == status else { + return nil + } + return existing.checks + }() + let state = SidebarPullRequestState( + number: number, + label: label, + url: url, + status: status, + branch: resolvedBranch, + checks: resolvedChecks + ) if existing != state { panelPullRequests[panelId] = state } @@ -5871,10 +5964,16 @@ final class Workspace: Identifiable, ObservableObject { } func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { - SidebarBranchOrdering.orderedUniquePullRequests( + let validPanelPullRequests = panelPullRequests.filter { panelId, state in + guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else { + return true + } + return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch + } + return SidebarBranchOrdering.orderedUniquePullRequests( orderedPanelIds: orderedPanelIds, - panelPullRequests: panelPullRequests, - fallbackPullRequest: pullRequest + panelPullRequests: validPanelPullRequests, + fallbackPullRequest: nil ) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7f4a85e2..ad61dc8a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -87,6 +87,43 @@ enum WorkspaceButtonFadeSettings { } } +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 @@ -128,6 +165,8 @@ struct cmuxApp: App { } init() { + UITestLaunchManifest.applyIfPresent() + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { Self.terminateForMissingLaunchTag() } @@ -576,9 +615,10 @@ struct cmuxApp: App { Divider() // Terminal semantics: - // Cmd+W closes the focused tab/surface (with confirmation if needed). When that - // was the last surface in the workspace, cmux removes the workspace and closes - // the window if it was also the last workspace. + // Cmd+W closes the focused tab/surface (with confirmation if needed). By + // default, closing the last surface also closes the workspace and the window + // if it was also the last workspace. Users can opt into keeping the workspace + // open instead. Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) { closePanelOrWindow() } @@ -711,7 +751,7 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } - Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) { + Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) { // Defer modal presentation until after AppKit finishes menu tracking. DispatchQueue.main.async { BrowserDataImportCoordinator.shared.presentImportDialog() @@ -2250,7 +2290,7 @@ private struct BrowserProfilePopoverDebugView: View { Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) .font(.system(size: 12)) - Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) + Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) .font(.system(size: 12)) } .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) @@ -2730,7 +2770,7 @@ private struct AboutPanelView: View { @Environment(\.openURL) private var openURL private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") - private let docsURL = URL(string: "https://cmux.dev/docs") + private let docsURL = URL(string: "https://cmux.com/docs") private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } @@ -3767,6 +3807,8 @@ struct SettingsView: View { @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + @AppStorage(LastSurfaceCloseShortcutSettings.key) + private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -3836,6 +3878,30 @@ struct SettingsView: View { ) } + private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { + !closeWorkspaceOnLastSurfaceShortcut + } + + private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding { + Binding( + get: { keepWorkspaceOpenOnLastSurfaceShortcut }, + set: { closeWorkspaceOnLastSurfaceShortcut = !$0 } + ) + } + + private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String { + if keepWorkspaceOpenOnLastSurfaceShortcut { + return String( + localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn", + defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." + ) + } + return String( + localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff", + defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -4294,6 +4360,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), + subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle + ) { + Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -5098,7 +5175,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser")) + Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 6) { @@ -5452,6 +5529,7 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey) defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/cmux.entitlements b/cmux.entitlements index d9bae6d6..09e191a5 100644 --- a/cmux.entitlements +++ b/cmux.entitlements @@ -14,7 +14,5 @@ com.apple.security.automation.apple-events - com.apple.developer.web-browser - diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index d6f2914c..1d868654 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -6,6 +6,8 @@ import XCTest @testable import cmux #endif +private let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" + @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] @@ -13,6 +15,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { override func setUp() { super.setUp() + // Prevent a single hanging test from consuming the entire CI timeout budget. + executionTimeAllowance = 30 actionsWithPersistedShortcut = Set( KeyboardShortcutSettings.Action.allCases.filter { UserDefaults.standard.object(forKey: $0.defaultsKey) != nil @@ -714,6 +718,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testCmdWKeepsLastSurfaceWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let defaults = UserDefaults.standard + let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let originalSetting { + defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let targetWindow = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected test window, manager, workspace, and focused panel") + return + } + + guard let event = makeKeyDownEvent( + key: "w", + modifiers: [.command], + keyCode: 13, + windowNumber: targetWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+W event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertNotNil( + self.window(withId: windowId), + "Cmd+W should keep the window open when the keep-workspace-open preference is enabled" + ) + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) + } + func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift new file mode 100644 index 00000000..487c680c --- /dev/null +++ b/cmuxTests/BrowserConfigTests.swift @@ -0,0 +1,3108 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 +var cmuxUnitTestInspectorOverrideInstalled = false + +extension CmuxWebView { + @objc func cmuxUnitTestInspector() -> NSObject? { + objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject + } +} + +extension WKWebView { + func cmuxSetUnitTestInspector(_ inspector: NSObject?) { + objc_setAssociatedObject( + self, + &cmuxUnitTestInspectorAssociationKey, + inspector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } +} + +func installCmuxUnitTestInspectorOverride() { + guard !cmuxUnitTestInspectorOverrideInstalled else { return } + + guard let replacementMethod = class_getInstanceMethod( + CmuxWebView.self, + #selector(CmuxWebView.cmuxUnitTestInspector) + ) else { + fatalError("Unable to locate test inspector replacement method") + } + + let added = class_addMethod( + CmuxWebView.self, + NSSelectorFromString("_inspector"), + method_getImplementation(replacementMethod), + method_getTypeEncoding(replacementMethod) + ) + guard added else { + fatalError("Unable to install CmuxWebView _inspector test override") + } + + cmuxUnitTestInspectorOverrideInstalled = true +} + +final class CmuxWebViewKeyEquivalentTests: XCTestCase { + private final class ActionSpy: NSObject { + private(set) var invoked: Bool = false + + @objc func didInvoke(_ sender: Any?) { + invoked = true + } + } + + private final class WindowCyclingActionSpy: NSObject { + weak var firstWindow: NSWindow? + weak var secondWindow: NSWindow? + private(set) var invocationCount = 0 + + @objc func cycleWindow(_ sender: Any?) { + invocationCount += 1 + guard let firstWindow, let secondWindow else { return } + + if NSApp.keyWindow === firstWindow { + secondWindow.makeKeyAndOrderFront(nil) + } else { + firstWindow.makeKeyAndOrderFront(nil) + } + } + } + + private final class FirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + private final class DelegateProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + } + + private final class FieldEditorProbeTextView: NSTextView { + private(set) var delegateReadCount = 0 + + override var delegate: NSTextViewDelegate? { + get { + delegateReadCount += 1 + return super.delegate + } + set { + super.delegate = newValue + } + } + + override var isFieldEditor: Bool { + get { true } + set {} + } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "n", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N + XCTAssertNotNil(event) + + XCTAssertTrue(webView.performKeyEquivalent(with: event!)) + XCTAssertTrue(spy.invoked) + } + + func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "w", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W + XCTAssertNotNil(event) + + XCTAssertTrue(webView.performKeyEquivalent(with: event!)) + XCTAssertTrue(spy.invoked) + } + + func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R + XCTAssertNotNil(event) + + XCTAssertTrue(webView.performKeyEquivalent(with: event!)) + XCTAssertTrue(spy.invoked) + } + + func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + @MainActor + func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(webView)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(webView.becomeFirstResponder()) + + _ = window.makeFirstResponder(webView) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy") + + webView.withPointerFocusAllowance { + XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary") + } + + @MainActor + func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(descendant)) + + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance") + + _ = window.makeFirstResponder(nil) + webView.withPointerFocusAllowance { + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + _ = window.makeFirstResponder(nil) + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy") + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: host.bounds) + slot.autoresizingMask = [.width, .height] + host.addSubview(slot) + + let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + slot.addSubview(webView) + + let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector focus to stay blocked without pointer click context" + ) + + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target" + ) + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + + let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + defer { + BrowserWindowPortalRegistry.detach(webView: webView) + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected bound portal slot") + return + } + + let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected bound portal inspector focus to stay blocked without pointer click context" + ) + + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) + XCTAssertTrue( + BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView, + "Expected portal registry to resolve the owning web view from a click inside inspector chrome" + ) + + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(inspector), + "Expected bound portal inspector click to bypass blocked policy through portal registry fallback" + ) + } + + @MainActor + func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40)) + container.addSubview(textView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + _ = window.makeFirstResponder(nil) + _ = window.makeFirstResponder(textView) + + XCTAssertEqual( + textView.delegateReadCount, + 0, + "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)" + ) + } + + @MainActor + func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + XCTAssertTrue(window.makeFirstResponder(fieldEditor)) + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(fieldEditor)) + XCTAssertEqual( + fieldEditor.delegateReadCount, + 0, + "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate" + ) + } + + @MainActor + func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40)) + container.addSubview(responder) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + _ = window.makeFirstResponder(nil) + cmuxWithWindowFirstResponderBypass { + XCTAssertFalse( + window.makeFirstResponder(responder), + "Bypass scope should block transient first-responder changes during devtools auto-restore" + ) + } + XCTAssertTrue(window.makeFirstResponder(responder)) + } + + @MainActor + func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let firstWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let secondWindow = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) + let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) + firstWindow.contentView = firstContainer + secondWindow.contentView = secondContainer + + let firstTerminal = GhosttyNSView(frame: firstContainer.bounds) + firstTerminal.autoresizingMask = [.width, .height] + firstContainer.addSubview(firstTerminal) + + let secondTerminal = GhosttyNSView(frame: secondContainer.bounds) + secondTerminal.autoresizingMask = [.width, .height] + secondContainer.addSubview(secondTerminal) + + let spy = WindowCyclingActionSpy() + spy.firstWindow = firstWindow + spy.secondWindow = secondWindow + installMenu( + target: spy, + action: #selector(WindowCyclingActionSpy.cycleWindow(_:)), + key: "`", + modifiers: [.command] + ) + + secondWindow.orderFront(nil) + firstWindow.makeKeyAndOrderFront(nil) + defer { + secondWindow.orderOut(nil) + firstWindow.orderOut(nil) + } + + XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: firstWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + NSApp.sendEvent(event) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action") + } + + @MainActor + func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let spy = ActionSpy() + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: "`", + modifiers: [.command] + ) + + window.makeKeyAndOrderFront(nil) + defer { + window.orderOut(nil) + } + + XCTAssertTrue(window.makeFirstResponder(webView)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event)) + _ = webView.performKeyEquivalent(with: event) + XCTAssertFalse( + spy.invoked, + "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder" + ) + } + + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: key, + modifiers: modifiers + ) + } + + private func installMenu( + target: NSObject, + action: Selector, + key: String, + modifiers: NSEvent.ModifierFlags + ) { + let mainMenu = NSMenu() + + let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") + let fileMenu = NSMenu(title: "File") + + let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key) + item.keyEquivalentModifierMask = modifiers + item.target = target + fileMenu.addItem(item) + + mainMenu.addItem(fileItem) + mainMenu.setSubmenu(fileMenu, for: fileItem) + + // Ensure NSApp exists and has a menu for performKeyEquivalent to consult. + _ = NSApplication.shared + NSApp.mainMenu = mainMenu + } + + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int = 0 + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + } +} + + +@MainActor +final class CmuxWebViewContextMenuTests: XCTestCase { + private func makeRightMouseDownEvent() -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: .rightMouseDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create rightMouseDown event") + } + return event + } + + func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() { + _ = NSApplication.shared + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "") + openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink") + menu.addItem(openLinkItem) + menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: "")) + + var openedURL: URL? + webView.contextMenuLinkURLProvider = { _, _, completion in + completion(URL(string: "https://example.com/docs")!) + } + webView.contextMenuDefaultBrowserOpener = { url in + openedURL = url + return true + } + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else { + XCTFail("Expected Open Link in Default Browser item in context menu") + return + } + guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else { + XCTFail("Expected Open Link item in context menu") + return + } + + XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1) + let defaultBrowserItem = menu.items[defaultBrowserItemIndex] + XCTAssertTrue(defaultBrowserItem.target === webView) + XCTAssertNotNil(defaultBrowserItem.action) + + let dispatched = NSApp.sendAction( + defaultBrowserItem.action!, + to: defaultBrowserItem.target, + from: defaultBrowserItem + ) + XCTAssertTrue(dispatched) + XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs") + } + + func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: "")) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) + } + + func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadImageToDisk:") + let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } + + func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadLinkToDisk:") + let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } +} + + +final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testIconCatalogIncludesExpandedChoices() { + XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) + } + + func testIconOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultIcon + ) + } + + func testColorOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultColor + ) + } + + func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() { + let defaults = makeIsolatedDefaults() + defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserProfilePopoverPaddingDefaultsWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + + func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + + func testCopyPayloadUsesPersistedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + } +} + + +final class BrowserThemeSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testDefaultsMatchConfiguredFallbacks() { + let defaults = makeIsolatedDefaults() + XCTAssertEqual( + BrowserThemeSettings.mode(defaults: defaults), + BrowserThemeSettings.defaultMode + ) + } + + func testModeReadsPersistedValue() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) + + defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light) + } + + func testModeMigratesLegacyForcedDarkModeFlag() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) + XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue) + + let otherDefaults = makeIsolatedDefaults() + otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system) + XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue) + } +} + + +final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { + func testSafariDefaultShortcutForToggleDeveloperTools() { + let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + XCTAssertEqual(shortcut.key, "i") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } + + func testSafariDefaultShortcutForShowJavaScriptConsole() { + let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + XCTAssertEqual(shortcut.key, "c") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } +} + + +@MainActor +final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { + let panel = BrowserPanel(workspaceId: UUID()) + let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool + XCTAssertEqual(developerExtras, true) + + if #available(macOS 13.3, *) { + XCTAssertTrue(panel.webView.isInspectable) + } + } + + func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() { + let panel = BrowserPanel(workspaceId: UUID()) + let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) + let updatedOpacity = 0.57 + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: updatedColor, + GhosttyNotificationKey.backgroundOpacity: updatedOpacity + ] + ) + + guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), + let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible under-page background colors") + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) + } + + func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertEqual(panel.displayTitle, "New tab") + XCTAssertFalse(panel.shouldRenderWebView) + XCTAssertTrue(panel.isShowingNewTabPage) + XCTAssertNil(panel.webView.url) + XCTAssertNil(panel.currentURL) + } + + func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertTrue(panel.isShowingNewTabPage) + panel.navigate(to: URL(string: "https://example.com")!) + XCTAssertFalse(panel.isShowingNewTabPage) + } + + func testBrowserPanelThemeModeUpdatesWebViewAppearance() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.setBrowserThemeMode(.dark) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua) + + panel.setBrowserThemeMode(.light) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua) + + panel.setBrowserThemeMode(.system) + XCTAssertNil(panel.webView.appearance) + } + + func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() { + let panel = BrowserPanel(workspaceId: UUID()) + let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: updatedColor, + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), + let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible under-page background colors") + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) + } +} + + +@MainActor +final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase { + private final class BrowserInsecureHTTPAlertSpy: NSAlert { + private(set) var beginSheetModalCallCount = 0 + private(set) var runModalCallCount = 0 + var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + beginSheetModalCallCount += 1 + handler?(nextResponse) + } + + override func runModal() -> NSApplication.ModalResponse { + runModalCallCount += 1 + return nextResponse + } + } + + func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.resetInsecureHTTPAlertHooksForTesting() } + + let alertSpy = BrowserInsecureHTTPAlertSpy() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alertSpy }, + windowProvider: { window } + ) + panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + } + + func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.resetInsecureHTTPAlertHooksForTesting() } + + let alertSpy = BrowserInsecureHTTPAlertSpy() + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alertSpy }, + windowProvider: { nil } + ) + panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) + XCTAssertEqual(alertSpy.runModalCallCount, 1) + } +} + + +final class BrowserNavigationNewTabDecisionTests: XCTestCase { + func testLinkActivatedCmdClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedPlainLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testOtherNavigationLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: false + ) + ) + } + + func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: true + ) + ) + } + + func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testNonLinkNavigationNeverForcesNewTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [.command], + buttonNumber: 2 + ) + ) + } +} + + +final class BrowserPopupDecisionTests: XCTestCase { + func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationPlainLeftClickCreatesPopup() { + XCTAssertTrue( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedCmdClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } +} + + +final class BrowserNilTargetFallbackDecisionTests: XCTestCase { + func testOtherNavigationDoesNotFallbackToNewTab() { + XCTAssertFalse( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .other + ) + ) + } + + func testLinkActivatedNavigationFallsBackToNewTab() { + XCTAssertTrue( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .linkActivated + ) + ) + } +} + + +final class BrowserPopupContentRectTests: XCTestCase { + func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { + let rect = browserPopupContentRect( + requestedWidth: 400, + requestedHeight: 300, + requestedX: 150, + requestedTopY: 120, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) + XCTAssertEqual(rect.width, 400, accuracy: 0.01) + XCTAssertEqual(rect.height, 300, accuracy: 0.01) + } + + func testExplicitCoordinatesClampToVisibleFrame() { + let rect = browserPopupContentRect( + requestedWidth: 1400, + requestedHeight: 1200, + requestedX: 900, + requestedTopY: -25, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) + XCTAssertEqual(rect.width, 1000, accuracy: 0.01) + XCTAssertEqual(rect.height, 800, accuracy: 0.01) + } + + func testMissingCoordinatesCentersPopup() { + let rect = browserPopupContentRect( + requestedWidth: 300, + requestedHeight: 200, + requestedX: nil, + requestedTopY: nil, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) + XCTAssertEqual(rect.width, 300, accuracy: 0.01) + XCTAssertEqual(rect.height, 200, accuracy: 0.01) + } +} + + +@MainActor +final class BrowserJavaScriptDialogDelegateTests: XCTestCase { + func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { + let panel = BrowserPanel(workspaceId: UUID()) + guard let uiDelegate = panel.webView.uiDelegate as? NSObject else { + XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject") + return + } + + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript alert handling" + ) + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript confirm handling" + ) + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript prompt handling" + ) + } +} + + +@MainActor +final class BrowserSessionHistoryRestoreTests: XCTestCase { + func testSessionNavigationHistorySnapshotUsesRestoredStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + let snapshot = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + snapshot.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual( + snapshot.forwardHistoryURLStrings, + ["https://example.com/d"] + ) + } + + func testSessionNavigationHistoryBackAndForwardUpdateStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + panel.goBack() + let afterBack = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"]) + XCTAssertEqual( + afterBack.forwardHistoryURLStrings, + ["https://example.com/c", "https://example.com/d"] + ) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + panel.goForward() + let afterForward = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + afterForward.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"]) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + } + + func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: URL(string: "https://example.com") + ) + let oldWebView = panel.webView + let oldInstanceID = panel.webViewInstanceID + + panel.debugSimulateWebContentProcessTermination() + + XCTAssertFalse(panel.webView === oldWebView) + XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID) + XCTAssertNotNil(panel.webView.navigationDelegate) + XCTAssertNotNil(panel.webView.uiDelegate) + } + + func testWebViewReplacementPreservesEmptyNewTabRenderState() { + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertFalse(panel.shouldRenderWebView) + + panel.debugSimulateWebContentProcessTermination() + + XCTAssertFalse(panel.shouldRenderWebView) + } + + func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + url: URL(string: "https://example.com"), + focus: false + ) + ) + + browser.restoreSessionNavigationHistory( + backHistoryURLStrings: ["https://example.com/prev"], + forwardHistoryURLStrings: ["https://example.com/next"], + currentURLString: "https://example.com/current" + ) + browser.startFind() + + workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208") + workspace.metadataBlocks["notes"] = SidebarMetadataBlock( + key: "notes", + markdown: "test", + priority: 0, + timestamp: Date() + ) + workspace.progress = SidebarProgressState(value: 0.5, label: "Loading") + workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false) + workspace.updatePanelPullRequest( + panelId: contextPanelId, + number: 1208, + label: "PR", + url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), + status: .open + ) + workspace.logEntries.append( + SidebarLogEntry( + message: "Issue #1208", + level: .info, + source: "test", + timestamp: Date() + ) + ) + workspace.surfaceListeningPorts[contextPanelId] = [3000] + workspace.recomputeListeningPorts() + + XCTAssertTrue(browser.shouldRenderWebView) + XCTAssertNotNil(browser.preferredURLStringForOmnibar()) + XCTAssertTrue(browser.canGoBack) + XCTAssertTrue(browser.canGoForward) + XCTAssertNotNil(browser.searchState) + XCTAssertFalse(workspace.statusEntries.isEmpty) + XCTAssertFalse(workspace.logEntries.isEmpty) + XCTAssertFalse(workspace.metadataBlocks.isEmpty) + XCTAssertNotNil(workspace.progress) + XCTAssertNotNil(workspace.gitBranch) + XCTAssertNotNil(workspace.pullRequest) + XCTAssertEqual(workspace.listeningPorts, [3000]) + + let priorWebView = browser.webView + let priorInstanceID = browser.webViewInstanceID + workspace.resetSidebarContext(reason: "test") + + XCTAssertTrue(workspace.statusEntries.isEmpty) + XCTAssertTrue(workspace.logEntries.isEmpty) + XCTAssertTrue(workspace.metadataBlocks.isEmpty) + XCTAssertNil(workspace.progress) + XCTAssertNil(workspace.gitBranch) + XCTAssertTrue(workspace.panelGitBranches.isEmpty) + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue(workspace.panelPullRequests.isEmpty) + XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) + XCTAssertTrue(workspace.listeningPorts.isEmpty) + XCTAssertFalse(browser.shouldRenderWebView) + XCTAssertNil(browser.preferredURLStringForOmnibar()) + XCTAssertFalse(browser.canGoBack) + XCTAssertFalse(browser.canGoForward) + XCTAssertNil(browser.searchState) + XCTAssertFalse(browser.webView === priorWebView) + XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID) + } + +} + + +@MainActor +final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class WKInspectorProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + private final class FakeInspector: NSObject { + enum HideBehavior { + case unsupported + case noEffect + case hides + } + + private(set) var attachCount = 0 + private(set) var showCount = 0 + private(set) var hideCount = 0 + private(set) var closeCount = 0 + private let hideBehavior: HideBehavior + private var visible = false + private var attached = false + + init(hideBehavior: HideBehavior = .unsupported) { + self.hideBehavior = hideBehavior + super.init() + } + + override func responds(to aSelector: Selector!) -> Bool { + guard NSStringFromSelector(aSelector) == "hide" else { + return super.responds(to: aSelector) + } + return hideBehavior != .unsupported + } + + @objc func isVisible() -> Bool { + visible + } + + @objc func isAttached() -> Bool { + attached + } + + @objc func attach() { + attachCount += 1 + attached = true + show() + } + + @objc func show() { + showCount += 1 + visible = true + } + + @objc func hide() { + hideCount += 1 + guard hideBehavior == .hides else { return } + visible = false + } + + @objc func close() { + closeCount += 1 + visible = false + attached = false + } + } + + override class func setUp() { + super.setUp() + installCmuxUnitTestInspectorOverride() + } + + private func makePanelWithInspector( + hideBehavior: FakeInspector.HideBehavior = .unsupported + ) -> (BrowserPanel, FakeInspector) { + let panel = BrowserPanel(workspaceId: UUID()) + let inspector = FakeInspector(hideBehavior: hideBehavior) + panel.webView.cmuxSetUnitTestInspector(inspector) + return (panel, inspector) + } + + private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? { + if let host = root as? WebViewRepresentable.HostContainerView { + return host + } + for subview in root.subviews { + if let host = findHostContainerView(in: subview) { + return host + } + } + return nil + } + + private func waitForDeveloperToolsTransitions() { + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { + if let slot = root as? WindowBrowserSlotView { + return slot + } + for subview in root.subviews { + if let slot = findWindowBrowserSlotView(in: subview) { + return slot + } + } + return nil + } + + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate WebKit closing inspector during detach/reattach churn. + inspector.close() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate user closing inspector before detach. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector() + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + } + + func testSyncCanPreserveVisibleIntentDuringDetachChurn() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate a transient close caused by view detach, not user intent. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) + + // The force-refresh request should be one-shot. + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) + } + + func testRefreshRequestTracksPendingStateUntilRestoreRuns() { + let (panel, _) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + } + + func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + } + + func testRapidToggleQueuesHideAfterOpenTransitionSettles() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + } + + func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() { + let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect) + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + + XCTAssertTrue(panel.toggleDeveloperTools()) + + XCTAssertEqual(inspector.hideCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertFalse(panel.isDeveloperToolsVisible()) + } + + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { + let (panel, _) = makePanelWithInspector() + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.hideDeveloperTools()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) + XCTAssertTrue(panel.showDeveloperTools()) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140)) + window.contentView?.addSubview(anchor) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + XCTAssertNotNil(panel.webView.superview) + + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: true, + useLocalInlineHosting: false, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) + + XCTAssertNotNil(panel.webView.superview) + window.orderOut(nil) + } + + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150)) + window.contentView?.addSubview(anchor) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + XCTAssertNotNil(panel.webView.superview) + + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: true, + useLocalInlineHosting: false, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) + + XCTAssertNotNil(panel.webView.superview) + window.orderOut(nil) + } + + func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height) + host.addSubview(panel.webView) + + let inspectorContainer = NSView( + frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80) + host.addSubview(panel.webView) + + let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) + + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let visibleHosting = NSHostingView(rootView: representable) + visibleHosting.frame = contentView.bounds + visibleHosting.autoresizingMask = [.width, .height] + contentView.addSubview(visibleHosting) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + visibleHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let visibleHost = findHostContainerView(in: visibleHosting) else { + XCTFail("Expected visible local host") + return + } + guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected visible local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + visibleSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: visibleSlot.bounds.width, + height: visibleSlot.bounds.height - inspectorView.frame.height + ) + visibleSlot.layoutSubtreeIfNeeded() + + let detachedRoot = NSView(frame: visibleHosting.frame) + let offWindowHosting = NSHostingView(rootView: representable) + offWindowHosting.frame = detachedRoot.bounds + offWindowHosting.autoresizingMask = [.width, .height] + detachedRoot.addSubview(offWindowHosting) + detachedRoot.layoutSubtreeIfNeeded() + offWindowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host") + XCTAssertTrue(visibleHost.window === window) + XCTAssertTrue( + panel.webView.superview === visibleSlot, + "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn" + ) + XCTAssertTrue( + inspectorView.superview === visibleSlot, + "An off-window replacement host should leave DevTools companion views in the visible local host" + ) + } + + func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let narrowHosting = NSHostingView(rootView: representable) + narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240) + contentView.addSubview(narrowHosting) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + narrowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected initial local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + initialSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: initialSlot.bounds.width, + height: initialSlot.bounds.height - inspectorView.frame.height + ) + initialSlot.layoutSubtreeIfNeeded() + + let replacementHosting = NSHostingView(rootView: representable) + replacementHosting.frame = contentView.bounds + replacementHosting.autoresizingMask = [.width, .height] + contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting) + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + replacementHosting.rootView = representable + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + narrowHosting.removeFromSuperview() + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let replacementHost = findHostContainerView(in: replacementHosting), + let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else { + XCTFail("Expected replacement local inline host") + return + } + + XCTAssertTrue( + panel.webView.superview === replacementSlot, + "A visible replacement local host should take over the hosted page" + ) + XCTAssertTrue( + inspectorView.superview === replacementSlot, + "A visible replacement local host should move the DevTools companion views with the page" + ) + XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5) + } +} + + +final class BrowserOmnibarCommandNavigationTests: XCTestCase { + func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() { + XCTAssertNil( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: false, + flags: [], + keyCode: 126 + ) + ) + XCTAssertNil( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.command], + keyCode: 126 + ) + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [], + keyCode: 125 + ), + 1 + ) + } + + func testArrowNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 125 + ), + 1 + ) + } + + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: false, + flags: [.command], + chars: "n" + ) + ) + + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command], + chars: "n" + ), + 1 + ) + + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command], + chars: "p" + ), + -1 + ) + + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .shift], + chars: "n" + ) + ) + + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control], + chars: "p" + ), + -1 + ) + + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control], + chars: "n" + ), + 1 + ) + } + + func testCommandNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control, .capsLock], + chars: "n" + ), + 1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .capsLock], + chars: "p" + ), + -1 + ) + } + + func testSubmitOnReturnIgnoresCapsLockModifier() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) + } +} + + +final class BrowserReturnKeyDownRoutingTests: XCTestCase { + func testRoutesForReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testRoutesForKeypadEnterWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 76, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testDoesNotRouteForNonEnterKey() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 13, + firstResponderIsBrowser: true, + flags: [] + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotBrowser() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: false, + flags: [] + ) + ) + } + + func testRoutesForShiftReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.shift] + ) + ) + } + + func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.command, .shift] + ) + ) + } + + func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.command] + ) + ) + } + + func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.option] + ) + ) + } + + func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true, + flags: [.control] + ) + ) + } +} + + +final class BrowserZoomShortcutActionTests: XCTestCase { + func testZoomInSupportsEqualsAndPlusVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) + } + + func testZoomOutSupportsMinusAndUnderscoreVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27), + .zoomOut + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27), + .zoomOut + ) + } + + func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() { + XCTAssertEqual( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ), + .zoomIn + ) + + XCTAssertNil( + browserZoomShortcutAction( + flags: [.command, .shift], + chars: ";", + keyCode: 41 + ) + ) + } + + func testZoomRequiresCommandWithoutOptionOrControl() { + XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27)) + } + + func testResetSupportsCommandZero() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29), + .reset + ) + } +} + + +final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { + func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "-", + keyCode: 27 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "0", + keyCode: 29 + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotGhostty() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: false, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + } + + func testDoesNotRouteForNonZoomShortcuts() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testRoutesForShiftedLiteralZoomShortcut() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command, .shift], + chars: ";", + keyCode: 41, + literalChars: "+" + ) + ) + } +} + + +final class BrowserSearchEngineTests: XCTestCase { + func testGoogleSearchURL() throws { + let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) + XCTAssertEqual(url.host, "www.google.com") + XCTAssertEqual(url.path, "/search") + XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) + } + + func testDuckDuckGoSearchURL() throws { + let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world")) + XCTAssertEqual(url.host, "duckduckgo.com") + XCTAssertEqual(url.path, "/") + XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) + } + + func testBingSearchURL() throws { + let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world")) + XCTAssertEqual(url.host, "www.bing.com") + XCTAssertEqual(url.path, "/search") + XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) + } +} + + +final class BrowserSearchSettingsTests: XCTestCase { + func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() { + let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) + XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) + } + + func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() { + let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) + XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) + + defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) + XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) + } +} + + +final class BrowserHistoryStoreTests: XCTestCase { + func testRecordVisitDedupesAndSuggests() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } + + let u1 = try XCTUnwrap(URL(string: "https://example.com/foo")) + let u2 = try XCTUnwrap(URL(string: "https://example.com/bar")) + + await MainActor.run { + store.recordVisit(url: u1, title: "Example Foo") + store.recordVisit(url: u2, title: "Example Bar") + store.recordVisit(url: u1, title: "Example Foo Updated") + } + + let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) } + XCTAssertEqual(suggestions.first?.url, "https://example.com/foo") + XCTAssertEqual(suggestions.first?.visitCount, 2) + XCTAssertEqual(suggestions.first?.title, "Example Foo Updated") + } + + func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let fileURL = tempDir.appendingPathComponent("browser_history.json") + let now = Date() + let seededEntries = [ + BrowserHistoryStore.Entry( + id: UUID(), + url: "https://go.dev/", + title: "The Go Programming Language", + lastVisited: now, + visitCount: 3 + ), + BrowserHistoryStore.Entry( + id: UUID(), + url: "https://www.google.com/", + title: "Google", + lastVisited: now.addingTimeInterval(-120), + visitCount: 2 + ), + ] + + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let data = try encoder.encode(seededEntries) + try data.write(to: fileURL, options: [.atomic]) + + let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } + let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) } + + XCTAssertGreaterThanOrEqual(suggestions.count, 2) + XCTAssertEqual(suggestions.first?.url, "https://go.dev/") + XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" })) + } +} + + +@MainActor +final class CmuxWebViewDragRoutingTests: XCTestCase { + func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { + XCTAssertTrue( + CmuxWebView.shouldRejectInternalPaneDrag([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), + ]) + ) + } + + func testAllowsRegularExternalFileDrops() { + XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) + } +} + +#if compiler(>=6.2) +@available(macOS 26.0, *) +private struct DragConfigurationOperationsSnapshot: Equatable { + let allowCopy: Bool + let allowMove: Bool + let allowDelete: Bool + let allowAlias: Bool +} + +@available(macOS 26.0, *) +private enum DragConfigurationSnapshotError: Error { + case missingBoolField(primary: String, fallback: String?) +} + +@available(macOS 26.0, *) +private func dragConfigurationOperationsSnapshot(from operations: T) throws -> DragConfigurationOperationsSnapshot { + let mirror = Mirror(reflecting: operations) + + func readBool(_ primary: String, fallback: String? = nil) throws -> Bool { + if let value = mirror.descendant(primary) as? Bool { + return value + } + if let fallback, let value = mirror.descendant(fallback) as? Bool { + return value + } + throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback) + } + + return try DragConfigurationOperationsSnapshot( + allowCopy: readBool("allowCopy", fallback: "_allowCopy"), + allowMove: readBool("allowMove", fallback: "_allowMove"), + allowDelete: readBool("allowDelete", fallback: "_allowDelete"), + allowAlias: readBool("allowAlias", fallback: "_allowAlias") + ) +} + + +final class BrowserLinkOpenSettingsTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testTerminalLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) + } + + func testTerminalLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + } + + func testExternalOpenPatternsDefaultToEmpty() { + XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty) + } + + func testExternalOpenLiteralPatternMatchesCaseInsensitively() { + defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://platform.OPENAI.com/account/usage", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternMatchesCaseInsensitively() { + defaults.set( + "re:^https?://[^/]*\\.example\\.com/(billing|usage)", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://FOO.example.com/BILLING", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternSupportsDigitCharacterClass() { + defaults.set( + "re:^https://example\\.com/usage/\\d+$", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/usage/42", + defaults: defaults + ) + ) + } + + func testExternalOpenPatternsIgnoreInvalidRegexEntries() { + defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/path", + defaults: defaults + ) + ) + } +} + + +final class BrowserNavigableURLResolutionTests: XCTestCase { + func testResolvesFileSchemeAsNavigableURL() throws { + let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html")) + XCTAssertTrue(resolved.isFileURL) + XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html") + } + + func testRejectsNonWebNonFileScheme() { + XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com")) + XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html")) + } + + func testRejectsHostOnlyFileURL() { + XCTAssertNil(resolveBrowserNavigableURL("file://example.html")) + } +} + + +final class BrowserReadAccessURLTests: XCTestCase { + func testUsesParentDirectoryForFileURL() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + let file = dir.appendingPathComponent("sample.html") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try "".write(to: file, atomically: true, encoding: .utf8) + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesDirectoryURLWhenTargetIsDirectory() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir)) + XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) + } + + func testUsesParentDirectoryWhenFileDoesNotExist() throws { + let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html") + let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing)) + XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL) + } + + func testReturnsNilForHostOnlyFileURL() throws { + let hostOnly = try XCTUnwrap(URL(string: "file://example.html")) + XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly)) + } +} + + +final class BrowserExternalNavigationSchemeTests: XCTestCase { + func testCustomAppSchemesOpenExternally() throws { + let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) + let slack = try XCTUnwrap(URL(string: "slack://open")) + let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) + let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) + + XCTAssertTrue(browserShouldOpenURLExternally(discord)) + XCTAssertTrue(browserShouldOpenURLExternally(slack)) + XCTAssertTrue(browserShouldOpenURLExternally(zoom)) + XCTAssertTrue(browserShouldOpenURLExternally(mailto)) + } + + func testEmbeddedBrowserSchemesStayInWebView() throws { + let https = try XCTUnwrap(URL(string: "https://example.com")) + let http = try XCTUnwrap(URL(string: "http://example.com")) + let about = try XCTUnwrap(URL(string: "about:blank")) + let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html")) + let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) + let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) + let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) + + XCTAssertFalse(browserShouldOpenURLExternally(https)) + XCTAssertFalse(browserShouldOpenURLExternally(http)) + XCTAssertFalse(browserShouldOpenURLExternally(about)) + XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(file)) + XCTAssertFalse(browserShouldOpenURLExternally(blob)) + XCTAssertFalse(browserShouldOpenURLExternally(javascript)) + XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) + } +} + + +final class BrowserHostWhitelistTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testEmptyWhitelistAllowsAll() { + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + } + + func testExactMatch() { + defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testExactMatchIsCaseInsensitive() { + defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) + } + + func testWildcardSuffix() { + defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testWildcardIsCaseInsensitive() { + defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) + } + + func testBlankLinesAndWhitespaceIgnored() { + defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testMixedExactAndWildcard() { + defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) + } + + func testDefaultWhitelistIsEmpty() { + let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults) + XCTAssertTrue(patterns.isEmpty) + } + + func testWildcardRequiresDotBoundary() { + defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) + } + + func testWhitelistNormalizesSchemesPortsAndTrailingDots() { + defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) + } + + func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { + defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testUnicodeWhitelistEntryMatchesPunycodeHost() { + defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) + } +} + + +final class BrowserOmnibarFocusPolicyTests: XCTestCase { + func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { + XCTAssertTrue( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: true, + nextResponderIsOtherTextField: false + ) + ) + } + + func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: true, + nextResponderIsOtherTextField: true + ) + ) + } + + func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + desiredOmnibarFocus: false, + nextResponderIsOtherTextField: false + ) + ) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift new file mode 100644 index 00000000..104cecbf --- /dev/null +++ b/cmuxTests/BrowserPanelTests.swift @@ -0,0 +1,2935 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + +final class BrowserPanelChromeBackgroundColorTests: XCTestCase { + func testLightModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .light) + } + + func testDarkModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .dark) + } + + private func assertResolvedColorMatchesTheme( + for colorScheme: ColorScheme, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) + + guard + let actual = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expected = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + + +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") + let defaultStore = BrowserHistoryStore.shared + let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) + defaultStore.clearHistory() + alternateStore.clearHistory() + defer { + defaultStore.clearHistory() + alternateStore.clearHistory() + } + + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) + let staleWebView = panel.webView + let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) + let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) + staleWebView.loadHTMLString( + "Stalestale", + baseURL: staleURL + ) + + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" + ) + } +} + + +@MainActor +final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { + func testRequestPersistsUntilAcknowledged() { + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + + let requestId = panel.requestAddressBarFocus() + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.acknowledgeAddressBarFocusRequest(requestId) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + + // Acknowledgement only clears the durable request; focus suppression follows + // explicit blur state transitions. + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + panel.endSuppressWebViewFocusForAddressBar() + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testRequestCoalescesWhilePending() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus() + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) + } + + func testStaleAcknowledgementDoesNotClearNewestRequest() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(firstRequest) + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertNotEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(firstRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(secondRequest) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + } +} + + +@MainActor +final class WindowBrowserHostViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + + private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x >= bounds.maxX - 12 ? nil : self + } + } + + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool { + guard let hit else { return false } + if hit === pageView || hit.isDescendant(of: pageView) { + return false + } + if hit === inspectorView || hit.isDescendant(of: inspectorView) { + return true + } + return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) + } + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Browser host must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } + + func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))) + appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height))) + contentView.addSubview(appSplit) + + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))) + inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height))) + host.addSubview(inspectorSplit) + + XCTAssertTrue( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + appSplit, + window: window, + hostView: host + ), + "App layout splits should still trigger browser portal geometry sync" + ) + XCTAssertFalse( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + inspectorSplit, + window: window, + hostView: host + ), + "Hosted DevTools/internal splits should not trigger browser portal geometry sync" + ) + } + + func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .mouseEntered + ) + ) + } + + func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], + eventType: .cursorUpdate + ) + ) + } + + func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { + XCTAssertFalse( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + func testHostViewKeepsHostedInspectorDividerInteractive() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + // Underlying app layout split that should still be pass-through. + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.dividerStyle = .thin + let appSplitDelegate = BonsplitMockSplitDelegate() + appSplit.delegate = appSplitDelegate + let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) + let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) + appSplit.addSubview(leading) + appSplit.addSubview(trailing) + contentView.addSubview(appSplit) + appSplit.adjustSubviews() + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + // WebKit inspector uses an internal split (page + console). Divider drags + // here must stay in hosted content, not pass through to appSplit behind it. + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = false + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) + let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(consoleView) + host.addSubview(inspectorSplit) + inspectorSplit.setPosition(160, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let appDividerPointInSplit = NSPoint( + x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5), + y: appSplit.bounds.midY + ) + let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil) + let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil) + XCTAssertNil( + host.hitTest(appDividerPointInHost), + "Underlying app split divider should still pass through with a hosted inspector split present" + ) + + let dividerPointInInspector = NSPoint( + x: inspectorSplit.bounds.midX, + y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5) + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let hit = host.hitTest(dividerPointInHost) + + XCTAssertNotNil( + hit, + "Inspector divider should receive hit-testing in hosted content, not pass through" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside inspector split subtree" + ) + } + } + + func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let inspectorSplit = NSSplitView(frame: slot.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) + let inspectorView = CapturingView( + frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height) + ) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(inspectorView) + slot.addSubview(inspectorSplit) + inspectorSplit.setPosition(1, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSplit = NSPoint( + x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5), + y: inspectorSplit.bounds.midY + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) + XCTAssertTrue( + abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, + "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" + ) + + let hit = host.hitTest(dividerPointInHost) + XCTAssertNotNil( + hit, + "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside hosted inspector split subtree at the slot edge" + ) + } + } + + func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: wrapper.bounds.width - 92, + height: wrapper.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + slot.needsLayout = true + slot.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } + + func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + dividerHit === host, + "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorView.frame.minX, 92) + } + + func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let inspectorView = TrailingEdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height) + ) + let pageView = PrimaryPageProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(inspectorView) + slot.addSubview(pageView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(inspectorView.frame.width, 92) + XCTAssertGreaterThan(pageView.frame.minX, 92) + } + + func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView(frame: slot.bounds) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))" + ) + } +} + + +@MainActor +final class BrowserPanelHostContainerViewTests: XCTestCase { + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class TrackingInspectorFrontendWebView: WKWebView { + private(set) var evaluatedJavaScript: [String] = [] + + @MainActor override func evaluateJavaScript( + _ javaScriptString: String, + completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil + ) { + evaluatedJavaScript.append(javaScriptString) + completionHandler?(nil, nil) + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + + private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x >= bounds.maxX - 12 ? nil : self + } + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY) + let interiorHit = host.hitTest(bodyPointInHost) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should claim the right-docked divider edge for the manual resize path" + ) + XCTAssertTrue( + interiorHit == nil || interiorHit !== host, + "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))" + ) + } + + func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView(frame: webViewRoot.bounds) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 0) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) + } + + func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "The custom DevTools divider should remain draggable at the top edge of the browser pane" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" + ) + } + + func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) + } + + func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThanOrEqual( + inspectorContainer.frame.width, + 120, + "Shrinking the DevTools pane should clamp to a recoverable minimum width" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "After clamping, the DevTools divider should still be draggable near the top edge" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "After clamping, the DevTools divider should still be draggable near the bottom edge" + ) + } + + func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" + ) + XCTAssertTrue( + pageView.superview === inspectorView.superview && pageView.superview !== slotView, + "Promotion should move both hosted inspector siblings into the managed side-dock container" + ) + XCTAssertEqual( + pageView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize the inspector height to the host height" + ) + } + + func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "The managed side-dock path should be active before drag assertions run" + ) + + let initialPageWidth = pageView.frame.width + let initialInspectorWidth = inspectorView.frame.width + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan( + inspectorView.frame.width, + initialInspectorWidth, + "Right-docked DevTools should expand when the divider is dragged left" + ) + XCTAssertLessThan( + pageView.frame.width, + initialPageWidth, + "Expanding right-docked DevTools should shrink the page width" + ) + } + + func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertGreaterThanOrEqual( + inspectorView.frame.width, + 120, + "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Automatic shrink should keep the inspector vertically normalized to the host height" + ) + } + + func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) + let inspectorView = TrackingInspectorFrontendWebView( + frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), + "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" + ) + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }), + "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls" + ) + } + + func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + guard let managedContainer = pageView.superview else { + XCTFail("Expected managed side-dock container") + return + } + let draggedPageFrame = pageView.frame + let draggedInspectorFrame = inspectorView.frame + + managedContainer.setFrameSize( + NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) + ) + + XCTAssertEqual( + pageView.frame.origin.x, + draggedPageFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should not autoresize the page back to a stale divider position" + ) + XCTAssertEqual( + pageView.frame.width, + draggedPageFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" + ) + XCTAssertEqual( + inspectorView.frame.origin.x, + draggedInspectorFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector origin" + ) + XCTAssertEqual( + inspectorView.frame.width, + draggedInspectorFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector width" + ) + } + + func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) + ) + let pageView = PrimaryPageProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(inspectorContainer) + webViewRoot.addSubview(pageView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(inspectorContainer.frame.width, 92) + XCTAssertGreaterThan(pageView.frame.minX, 92) + } + + func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView( + frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height) + ) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: webViewRoot.bounds.width - 92, + height: webViewRoot.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + host.needsLayout = true + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } + + func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180)) + let webView = WKWebView(frame: .zero) + slot.addSubview(webView) + + slot.pinHostedWebView(webView) + slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220) + slot.layoutSubtreeIfNeeded() + + XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints) + XCTAssertEqual(webView.autoresizingMask, [.width, .height]) + XCTAssertEqual(webView.frame, slot.bounds) + } + + func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180)) + let webView = WKWebView(frame: .zero) + slot.addSubview(webView) + slot.pinHostedWebView(webView) + XCTAssertEqual(webView.frame, slot.bounds) + + let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180)) + webView.removeFromSuperview() + externalHost.addSubview(webView) + webView.frame = externalHost.bounds + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + + slot.addSubview(webView) + slot.pinHostedWebView(webView) + + slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180) + slot.layoutSubtreeIfNeeded() + + XCTAssertEqual( + webView.frame, + slot.bounds, + "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host" + ) + } +} + + +@MainActor +final class BrowserPaneDropRoutingTests: XCTestCase { + func testVerticalZonesFollowAppKitCoordinates() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), + .top + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), + .bottom + ) + } + + func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 110), + in: size, + topChromeHeight: 36 + ), + .center + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 150), + in: size, + topChromeHeight: 36 + ), + .top + ) + } + + func testHitTestingCapturesOnlyForRelevantDragEvents() { + XCTAssertTrue( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .leftMouseDown + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + func testCenterDropOnSamePaneIsNoOp() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let transfer = BrowserPaneDragTransfer( + tabId: UUID(), + sourcePaneId: paneId.id, + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), + .noOp + ) + } + + func testRightEdgeDropBuildsSplitMoveAction() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let tabId = UUID() + let transfer = BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), + .move( + tabId: tabId, + targetWorkspaceId: target.workspaceId, + targetPane: paneId, + splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + ) + ) + } + + func testDecodeTransferPayloadReadsTabAndSourcePane() { + let tabId = UUID() + let sourcePaneId = UUID() + let payload = try! JSONSerialization.data( + withJSONObject: [ + "tab": ["id": tabId.uuidString], + "sourcePaneId": sourcePaneId.uuidString, + "sourceProcessId": ProcessInfo.processInfo.processIdentifier, + ] + ) + + let transfer = BrowserPaneDragTransfer.decode(from: payload) + + XCTAssertEqual(transfer?.tabId, tabId) + XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) + XCTAssertTrue(transfer?.isFromCurrentProcess == true) + } +} + + +@MainActor +final class WindowBrowserSlotViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + let child = CapturingView(frame: slot.bounds) + child.autoresizingMask = [.width, .height] + slot.addSubview(child) + + slot.setDropZoneOverlay(zone: .right) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5) + XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits") + XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") + } + + func testTopDropZoneOverlayUsesFullBrowserContentHeight() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + + slot.setPaneTopChromeHeight(20) + slot.setDropZoneOverlay(zone: .top) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) + XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) + XCTAssertEqual(slot.layer?.masksToBounds, true) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertEqual(slot.layer?.masksToBounds, true) + } +} + + +@MainActor +final class BrowserWindowPortalLifecycleTests: XCTestCase { + private final class TrackingPortalWebView: WKWebView { + private(set) var displayIfNeededCount = 0 + private(set) var reattachRenderingStateCount = 0 + + override func displayIfNeeded() { + displayIfNeededCount += 1 + super.displayIfNeeded() + } + + @objc(_enterInWindow) + func cmuxUnitTestEnterInWindow() { + reattachRenderingStateCount += 1 + } + + @objc(_endDeferringViewInWindowChangesSync) + func cmuxUnitTestEndDeferringViewInWindowChangesSync() { + reattachRenderingStateCount += 1 + } + } + + private final class WKInspectorProbeView: NSView {} + + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { + let candidates = slot.subviews + (slot.superview?.subviews ?? []) + return candidates.first(where: { + $0 !== slot && + $0 !== webView && + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) + } + + func testPortalHostInstallsAboveContentViewForVisibility() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowBrowserPortal(window: window) + _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Browser portal host must remain above content view so portal-hosted web views stay visible" + ) + } + + func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertGreaterThan( + browserHostIndex, + terminalHostIndex, + message + ) + } + + assertHostOrder("Browser portal host should start above terminal portal host") + + let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140)) + contentView.addSubview(terminalAnchor) + let terminalHostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor) + assertHostOrder("Terminal portal sync should not rise above the browser portal host") + + let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140)) + contentView.addSubview(browserAnchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true) + browserPortal.synchronizeWebViewForAnchor(browserAnchor) + assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals") + } + + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + let firstSuperview = webView.superview + + XCTAssertNotNil(firstSuperview) + XCTAssertTrue(firstSuperview is WindowBrowserSlotView) + + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view") + + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor2) + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected browser slot + host views") + return + } + let expectedFrame = host.convert(anchor2.bounds, from: anchor2) + XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5) + } + + func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + // Simulate a transient oversized anchor rect during split churn. + let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected web view slot") + return + } + + XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible") + XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) + } + + func testPortalClipsAnchorFrameThroughAncestorBounds() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120)) + contentView.addSubview(clipView) + + // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane. + let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120)) + clipView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + clipView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane") + XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5) + } + + func testPortalSyncNormalizesOutOfBoundsWebFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds. + webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height) + XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY) + + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5) + XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) + } + + func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) + let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(webView) + slot.addSubview(inspectorContainer) + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + slot.pinHostedWebView(webView) + + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "The page frame should stay narrower than the full slot while a side-docked inspector is present" + ) + } + + func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialInspectorWidth: CGFloat = 110 + let inspectorContainer = NSView( + frame: NSRect( + x: slot.bounds.width - initialInspectorWidth, + y: 0, + width: initialInspectorWidth, + height: slot.bounds.height + ) + ) + inspectorContainer.autoresizingMask = [.minXMargin, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: 0, + width: slot.bounds.width - initialInspectorWidth, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "Side-docked inspector should still own part of the slot after pane resize" + ) + } + + func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible") + XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + initialDisplayCount, + "Pure anchor geometry updates should still repaint the hosted browser" + ) + XCTAssertEqual( + webView.reattachRenderingStateCount, + initialReattachCount, + "Pure anchor geometry updates should not trigger the WebKit reattach path" + ) + } + + func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 360), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + + let leadingPane = NSView( + frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height) + ) + leadingPane.autoresizingMask = [.height] + let trailingPane = NSView( + frame: NSRect( + x: 221, + y: 0, + width: contentView.bounds.width - 221, + height: contentView.bounds.height + ) + ) + trailingPane.autoresizingMask = [.width, .height] + splitView.addSubview(leadingPane) + splitView.addSubview(trailingPane) + contentView.addSubview(splitView) + splitView.adjustSubviews() + + let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12)) + anchor.autoresizingMask = [.width, .height] + trailingPane.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + let initialWidth = slot.frame.width + + splitView.setPosition(280, ofDividerAt: 0) + contentView.layoutSubtreeIfNeeded() + NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView) + advanceAnimations() + + XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible") + XCTAssertLessThan( + slot.frame.width, + initialWidth, + "Moving the app split divider should shrink the hosted browser slot" + ) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + initialDisplayCount, + "External split resize should still repaint the hosted browser" + ) + XCTAssertEqual( + webView.reattachRenderingStateCount, + initialReattachCount, + "External split resize should not trigger the WebKit reattach path" + ) + } + + func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let inspectorHeight: CGFloat = 84 + let inspectorContainer = NSView( + frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight) + ) + inspectorContainer.autoresizingMask = [.width] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: inspectorHeight, + width: slot.bounds.width, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.minY, + inspectorHeight, + accuracy: 0.5, + "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward" + ) + XCTAssertEqual( + webView.frame.height, + slot.bounds.height - inspectorHeight, + accuracy: 0.5, + "Portal sync should shrink the page viewport to the space above a bottom-docked inspector" + ) + XCTAssertEqual( + webView.frame.maxY, + slot.bounds.maxY, + accuracy: 0.5, + "The repaired page viewport should stay flush with the top edge of the slot" + ) + } + + func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(slot) + + let inspectorContainer = NSView(frame: slot.bounds) + inspectorContainer.autoresizingMask = [.width, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + window.makeFirstResponder(inspectorView), + "Precondition failed: inspector probe should become first responder" + ) + XCTAssertTrue(window.firstResponder === inspectorView) + + slot.isHidden = true + + XCTAssertFalse( + window.firstResponder === inspectorView, + "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" + ) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse( + firstResponderView === slot || firstResponderView.isDescendant(of: slot), + "Hiding a browser slot should not leave first responder inside the hidden slot" + ) + } + } + + func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden") + + let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame) + contentView.addSubview(localInlineSlot) + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + localInlineSlot.addSubview(inspectorView) + + localInlineSlot.addSubview(webView) + webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: localInlineSlot.bounds.width, + height: localInlineSlot.bounds.height - inspectorView.frame.height + ) + localInlineSlot.layoutSubtreeIfNeeded() + + anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) + localInlineSlot.frame = anchor.frame + contentView.layoutSubtreeIfNeeded() + localInlineSlot.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertTrue( + webView.superview === localInlineSlot, + "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize" + ) + XCTAssertTrue( + inspectorView.superview === localInlineSlot, + "Hidden portal sync should leave local DevTools companion views in the local inline host" + ) + XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting") + } + + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected portal slot + host views") + return + } + XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync") + XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") + } + + func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let overlay = dropZoneOverlay(in: slot, excluding: webView) else { + XCTFail("Expected browser slot overlay") + return + } + + XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone") + + portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) + slot.layoutSubtreeIfNeeded() + XCTAssertFalse(overlay.isHidden) + XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") + XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay") + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay") + } + + func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let initialDisplayCount = webView.displayIfNeededCount + let initialReattachCount = webView.reattachRenderingStateCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let hiddenDisplayCount = webView.displayIfNeededCount + let hiddenReattachCount = webView.reattachRenderingStateCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) + XCTAssertEqual( + hiddenReattachCount, + initialReattachCount, + "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path" + ) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + hiddenDisplayCount, + "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" + ) + XCTAssertGreaterThan( + webView.reattachRenderingStateCount, + hiddenReattachCount, + "Revealing an existing portal-hosted browser should trigger the WebKit reattach path" + ) + } + + func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor1 = NSView(frame: anchorFrame) + contentView.addSubview(anchor1) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + anchor1.removeFromSuperview() + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") + XCTAssertTrue( + slot.isHidden, + "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + let displayCountBeforeRebind = webView.displayIfNeededCount + let anchor2 = NSView(frame: anchorFrame) + contentView.addSubview(anchor2) + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor2) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" + ) + } + + func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor = NSView(frame: anchorFrame) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let offWindowContainer = NSView(frame: anchorFrame) + anchor.removeFromSuperview() + offWindowContainer.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Off-window anchor reparent should preserve the hosted browser slot during drag churn" + ) + XCTAssertFalse( + slot.isHidden, + "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + contentView.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + } + + func testRegistryDetachRemovesPortalHostedWebView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + XCTAssertNotNil(webView.superview) + + BrowserWindowPortalRegistry.detach(webView: webView) + XCTAssertNil(webView.superview) + } + + func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + XCTAssertFalse(slot.isHidden) + + BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") + XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") + } + + func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let oldAnchor = NSView(frame: anchorFrame) + contentView.addSubview(oldAnchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: oldAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount") + + oldAnchor.removeFromSuperview() + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted" + ) + XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound") + XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive") + + let displayCountBeforeRebind = webView.displayIfNeededCount + let newAnchor = NSView(frame: anchorFrame) + contentView.addSubview(newAnchor) + portal.bind(webView: webView, to: newAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(newAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Selecting the workspace again should reuse the existing hidden browser portal slot" + ) + XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot") + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Workspace rebind should refresh the preserved browser without recreating its portal slot" + ) + } +} diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 849c2616..80c7d8c6 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import ObjectiveC.runtime #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,6 +8,64 @@ import AppKit @testable import cmux #endif +private var cjkIMEInterpretKeyEventsSwizzled = false +private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? + +private extension GhosttyNSView { + @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { + if let hook = cjkIMEInterpretKeyEventsHook, hook(self, eventArray) { + return + } + cmuxUnitTest_interpretKeyEvents(eventArray) + } +} + +private func installCJKIMEInterpretKeyEventsSwizzle() { + guard !cjkIMEInterpretKeyEventsSwizzled else { return } + + let originalSelector = #selector(GhosttyNSView.interpretKeyEvents(_:)) + let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_interpretKeyEvents(_:)) + + guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { + fatalError("Unable to locate GhosttyNSView interpretKeyEvents methods for swizzling") + } + + let didAddMethod = class_addMethod( + GhosttyNSView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + GhosttyNSView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + cjkIMEInterpretKeyEventsSwizzled = true +} + +private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { + if let view = view as? GhosttyNSView { + return view + } + + for subview in view.subviews { + if let match = findGhosttyNSView(in: subview) { + return match + } + } + + return nil +} + // MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle /// Tests that the GhosttyNSView NSTextInputClient implementation correctly @@ -932,6 +991,95 @@ final class GhosttySpaceReleaseRegressionTests: XCTestCase { } } +@MainActor +final class KoreanIMEReturnCommitRegressionTests: XCTestCase { + func testReturnAfterKoreanCommitAlsoSendsReturnToSurface() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let view = findGhosttyNSView(in: hostedView) else { + XCTFail("Expected hosted GhosttyNSView") + return + } + + view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0)) + + installCJKIMEInterpretKeyEventsSwizzle() + cjkIMEInterpretKeyEventsHook = { candidateView, _ in + guard candidateView === view else { return false } + candidateView.insertText("한", replacementRange: NSRange(location: NSNotFound, length: 0)) + return true + } + defer { + cjkIMEInterpretKeyEventsHook = nil + } + + var sawReturnPress = false + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_PRESS, + keyEvent.keycode == 36, + keyEvent.text == nil else { return } + sawReturnPress = true + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "\r", + charactersIgnoringModifiers: "\r", + isARepeat: false, + keyCode: 36 + ) else { + XCTFail("Failed to create Return event") + return + } + + window.makeFirstResponder(view) + view.keyDown(with: event) + + XCTAssertFalse(view.hasMarkedText(), "Return should commit the active Hangul composition") + XCTAssertTrue(sawReturnPress, "Return should still be forwarded after IME commit so the command executes once") + } +} + final class GhosttyBackquoteRegressionTests: XCTestCase { func testShiftBackquoteEscFallbackSendsLiteralTilde() { _ = NSApplication.shared diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift deleted file mode 100644 index 96b4083b..00000000 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ /dev/null @@ -1,15658 +0,0 @@ -import XCTest -import AppKit -import SwiftUI -import UniformTypeIdentifiers -import WebKit -import SwiftUI -import ObjectiveC.runtime -import Bonsplit -import UserNotifications - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 -private var cmuxUnitTestInspectorOverrideInstalled = false - -private extension CmuxWebView { - @objc func cmuxUnitTestInspector() -> NSObject? { - objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject - } -} - -private extension WKWebView { - func cmuxSetUnitTestInspector(_ inspector: NSObject?) { - objc_setAssociatedObject( - self, - &cmuxUnitTestInspectorAssociationKey, - inspector, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } -} - -private func installCmuxUnitTestInspectorOverride() { - guard !cmuxUnitTestInspectorOverrideInstalled else { return } - - guard let replacementMethod = class_getInstanceMethod( - CmuxWebView.self, - #selector(CmuxWebView.cmuxUnitTestInspector) - ) else { - fatalError("Unable to locate test inspector replacement method") - } - - let added = class_addMethod( - CmuxWebView.self, - NSSelectorFromString("_inspector"), - method_getImplementation(replacementMethod), - method_getTypeEncoding(replacementMethod) - ) - guard added else { - fatalError("Unable to install CmuxWebView _inspector test override") - } - - cmuxUnitTestInspectorOverrideInstalled = true -} - -private func drainMainQueue() { - let expectation = XCTestExpectation(description: "drain main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - XCTWaiter().wait(for: [expectation], timeout: 1.0) -} - -@MainActor -private func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) -} - -final class SplitShortcutTransientFocusGuardTests: XCTestCase { - func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { - XCTAssertTrue( - shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: true, - hostedSize: CGSize(width: 79, height: 0), - hostedHiddenInHierarchy: false, - hostedAttachedToWindow: true - ) - ) - } - - func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { - XCTAssertTrue( - shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: true, - hostedSize: CGSize(width: 1051.5, height: 1207), - hostedHiddenInHierarchy: false, - hostedAttachedToWindow: false - ) - ) - } - - func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { - XCTAssertFalse( - shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: true, - hostedSize: CGSize(width: 1051.5, height: 1207), - hostedHiddenInHierarchy: false, - hostedAttachedToWindow: true - ) - ) - } - - func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { - XCTAssertFalse( - shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: false, - hostedSize: CGSize(width: 79, height: 0), - hostedHiddenInHierarchy: false, - hostedAttachedToWindow: true - ) - ) - } -} - -final class CmuxWebViewKeyEquivalentTests: XCTestCase { - private final class ActionSpy: NSObject { - private(set) var invoked: Bool = false - - @objc func didInvoke(_ sender: Any?) { - invoked = true - } - } - - private final class WindowCyclingActionSpy: NSObject { - weak var firstWindow: NSWindow? - weak var secondWindow: NSWindow? - private(set) var invocationCount = 0 - - @objc func cycleWindow(_ sender: Any?) { - invocationCount += 1 - guard let firstWindow, let secondWindow else { return } - - if NSApp.keyWindow === firstWindow { - secondWindow.makeKeyAndOrderFront(nil) - } else { - firstWindow.makeKeyAndOrderFront(nil) - } - } - } - - private final class FirstResponderView: NSView { - override var acceptsFirstResponder: Bool { true } - } - - private final class DelegateProbeTextView: NSTextView { - private(set) var delegateReadCount = 0 - - override var delegate: NSTextViewDelegate? { - get { - delegateReadCount += 1 - return super.delegate - } - set { - super.delegate = newValue - } - } - } - - private final class FieldEditorProbeTextView: NSTextView { - private(set) var delegateReadCount = 0 - - override var delegate: NSTextViewDelegate? { - get { - delegateReadCount += 1 - return super.delegate - } - set { - super.delegate = newValue - } - } - - override var isFieldEditor: Bool { - get { true } - set {} - } - } - func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "n", modifiers: [.command]) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N - XCTAssertNotNil(event) - - XCTAssertTrue(webView.performKeyEquivalent(with: event!)) - XCTAssertTrue(spy.invoked) - } - - func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "w", modifiers: [.command]) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W - XCTAssertNotNil(event) - - XCTAssertTrue(webView.performKeyEquivalent(with: event!)) - XCTAssertTrue(spy.invoked) - } - - func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "r", modifiers: [.command]) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R - XCTAssertNotNil(event) - - XCTAssertTrue(webView.performKeyEquivalent(with: event!)) - XCTAssertTrue(spy.invoked) - } - - func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "\r", modifiers: []) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return - XCTAssertNotNil(event) - - XCTAssertFalse(webView.performKeyEquivalent(with: event!)) - XCTAssertFalse(spy.invoked) - } - - func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "\r", modifiers: [.command]) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return - XCTAssertNotNil(event) - - XCTAssertFalse(webView.performKeyEquivalent(with: event!)) - XCTAssertFalse(spy.invoked) - } - - func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { - let spy = ActionSpy() - installMenu(spy: spy, key: "\r", modifiers: []) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter - XCTAssertNotNil(event) - - XCTAssertFalse(webView.performKeyEquivalent(with: event!)) - XCTAssertFalse(spy.invoked) - } - - @MainActor - func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { - _ = NSApplication.shared - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - webView.allowsFirstResponderAcquisition = true - XCTAssertTrue(window.makeFirstResponder(webView)) - - _ = window.makeFirstResponder(nil) - webView.allowsFirstResponderAcquisition = false - XCTAssertFalse(webView.becomeFirstResponder()) - - _ = window.makeFirstResponder(webView) - if let firstResponderView = window.firstResponder as? NSView { - XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) - } - } - - @MainActor - func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() { - _ = NSApplication.shared - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - webView.allowsFirstResponderAcquisition = false - _ = window.makeFirstResponder(nil) - XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy") - - webView.withPointerFocusAllowance { - XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy") - } - - _ = window.makeFirstResponder(nil) - XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary") - } - - @MainActor - func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) - webView.addSubview(descendant) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - webView.allowsFirstResponderAcquisition = true - XCTAssertTrue(window.makeFirstResponder(descendant)) - - _ = window.makeFirstResponder(nil) - webView.allowsFirstResponderAcquisition = false - XCTAssertFalse(window.makeFirstResponder(descendant)) - - if let firstResponderView = window.firstResponder as? NSView { - XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) - } - } - - @MainActor - func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) - webView.addSubview(descendant) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - webView.allowsFirstResponderAcquisition = false - _ = window.makeFirstResponder(nil) - XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance") - - _ = window.makeFirstResponder(nil) - webView.withPointerFocusAllowance { - XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard") - } - - _ = window.makeFirstResponder(nil) - XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary") - } - - @MainActor - func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) - webView.addSubview(descendant) - - window.makeKeyAndOrderFront(nil) - defer { - AppDelegate.clearWindowFirstResponderGuardTesting() - window.orderOut(nil) - } - - webView.allowsFirstResponderAcquisition = false - _ = window.makeFirstResponder(nil) - XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") - - let timestamp = ProcessInfo.processInfo.systemUptime - let pointerDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 5, y: 5), - modifierFlags: [], - timestamp: timestamp, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1.0 - ) - XCTAssertNotNil(pointerDownEvent) - - AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) - _ = window.makeFirstResponder(nil) - XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy") - - AppDelegate.clearWindowFirstResponderGuardTesting() - _ = window.makeFirstResponder(nil) - XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") - } - - @MainActor - func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = contentView - - window.makeKeyAndOrderFront(nil) - defer { - AppDelegate.clearWindowFirstResponderGuardTesting() - window.orderOut(nil) - } - - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: host.bounds) - slot.autoresizingMask = [.width, .height] - host.addSubview(slot) - - let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - slot.addSubview(webView) - - let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height)) - inspector.autoresizingMask = [.minXMargin, .height] - slot.addSubview(inspector) - - webView.allowsFirstResponderAcquisition = false - _ = window.makeFirstResponder(nil) - XCTAssertFalse( - window.makeFirstResponder(inspector), - "Expected portal-hosted inspector focus to stay blocked without pointer click context" - ) - - let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) - let pointInWindow = inspector.convert(pointInInspector, to: nil) - let pointerDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: pointInWindow, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1.0 - ) - XCTAssertNotNil(pointerDownEvent) - - AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) - _ = window.makeFirstResponder(nil) - XCTAssertTrue( - window.makeFirstResponder(inspector), - "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target" - ) - } - - @MainActor - func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = contentView - - let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - - window.makeKeyAndOrderFront(nil) - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - - defer { - BrowserWindowPortalRegistry.detach(webView: webView) - AppDelegate.clearWindowFirstResponderGuardTesting() - window.orderOut(nil) - } - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected bound portal slot") - return - } - - let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height)) - inspector.autoresizingMask = [.minXMargin, .height] - slot.addSubview(inspector) - - webView.allowsFirstResponderAcquisition = false - _ = window.makeFirstResponder(nil) - XCTAssertFalse( - window.makeFirstResponder(inspector), - "Expected bound portal inspector focus to stay blocked without pointer click context" - ) - - let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) - let pointInWindow = inspector.convert(pointInInspector, to: nil) - XCTAssertTrue( - BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView, - "Expected portal registry to resolve the owning web view from a click inside inspector chrome" - ) - - let pointerDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: pointInWindow, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1.0 - ) - XCTAssertNotNil(pointerDownEvent) - - AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) - _ = window.makeFirstResponder(nil) - XCTAssertTrue( - window.makeFirstResponder(inspector), - "Expected bound portal inspector click to bypass blocked policy through portal registry fallback" - ) - } - - @MainActor - func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40)) - container.addSubview(textView) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - _ = window.makeFirstResponder(nil) - _ = window.makeFirstResponder(textView) - - XCTAssertEqual( - textView.delegateReadCount, - 0, - "WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)" - ) - } - - @MainActor - func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) - webView.addSubview(descendant) - - let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) - - window.makeKeyAndOrderFront(nil) - defer { - AppDelegate.clearWindowFirstResponderGuardTesting() - window.orderOut(nil) - } - - webView.allowsFirstResponderAcquisition = true - XCTAssertTrue(window.makeFirstResponder(descendant)) - - let timestamp = ProcessInfo.processInfo.systemUptime - let pointerDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 5, y: 5), - modifierFlags: [], - timestamp: timestamp, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1.0 - ) - XCTAssertNotNil(pointerDownEvent) - - AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) - XCTAssertTrue(window.makeFirstResponder(fieldEditor)) - - AppDelegate.clearWindowFirstResponderGuardTesting() - _ = window.makeFirstResponder(nil) - webView.allowsFirstResponderAcquisition = false - XCTAssertFalse(window.makeFirstResponder(fieldEditor)) - XCTAssertEqual( - fieldEditor.delegateReadCount, - 0, - "Field-editor webview ownership should come from tracked associations, not NSTextView.delegate" - ) - } - - @MainActor - func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40)) - container.addSubview(responder) - - window.makeKeyAndOrderFront(nil) - defer { window.orderOut(nil) } - - _ = window.makeFirstResponder(nil) - cmuxWithWindowFirstResponderBypass { - XCTAssertFalse( - window.makeFirstResponder(responder), - "Bypass scope should block transient first-responder changes during devtools auto-restore" - ) - } - XCTAssertTrue(window.makeFirstResponder(responder)) - } - - @MainActor - func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let firstWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let secondWindow = NSWindow( - contentRect: NSRect(x: 40, y: 40, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - - let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) - let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) - firstWindow.contentView = firstContainer - secondWindow.contentView = secondContainer - - let firstTerminal = GhosttyNSView(frame: firstContainer.bounds) - firstTerminal.autoresizingMask = [.width, .height] - firstContainer.addSubview(firstTerminal) - - let secondTerminal = GhosttyNSView(frame: secondContainer.bounds) - secondTerminal.autoresizingMask = [.width, .height] - secondContainer.addSubview(secondTerminal) - - let spy = WindowCyclingActionSpy() - spy.firstWindow = firstWindow - spy.secondWindow = secondWindow - installMenu( - target: spy, - action: #selector(WindowCyclingActionSpy.cycleWindow(_:)), - key: "`", - modifiers: [.command] - ) - - secondWindow.orderFront(nil) - firstWindow.makeKeyAndOrderFront(nil) - defer { - secondWindow.orderOut(nil) - firstWindow.orderOut(nil) - } - - XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal)) - guard let event = makeKeyDownEvent( - key: "`", - modifiers: [.command], - keyCode: 50, - windowNumber: firstWindow.windowNumber - ) else { - XCTFail("Failed to construct Cmd+` event") - return - } - - NSApp.sendEvent(event) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action") - } - - @MainActor - func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() { - _ = NSApplication.shared - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) - webView.autoresizingMask = [.width, .height] - container.addSubview(webView) - - let spy = ActionSpy() - installMenu( - target: spy, - action: #selector(ActionSpy.didInvoke(_:)), - key: "`", - modifiers: [.command] - ) - - window.makeKeyAndOrderFront(nil) - defer { - window.orderOut(nil) - } - - XCTAssertTrue(window.makeFirstResponder(webView)) - guard let event = makeKeyDownEvent( - key: "`", - modifiers: [.command], - keyCode: 50, - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Cmd+` event") - return - } - - XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event)) - _ = webView.performKeyEquivalent(with: event) - XCTAssertFalse( - spy.invoked, - "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder" - ) - } - - private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { - installMenu( - target: spy, - action: #selector(ActionSpy.didInvoke(_:)), - key: key, - modifiers: modifiers - ) - } - - private func installMenu( - target: NSObject, - action: Selector, - key: String, - modifiers: NSEvent.ModifierFlags - ) { - let mainMenu = NSMenu() - - let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") - let fileMenu = NSMenu(title: "File") - - let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key) - item.keyEquivalentModifierMask = modifiers - item.target = target - fileMenu.addItem(item) - - mainMenu.addItem(fileItem) - mainMenu.setSubmenu(fileMenu, for: fileItem) - - // Ensure NSApp exists and has a menu for performKeyEquivalent to consult. - _ = NSApplication.shared - NSApp.mainMenu = mainMenu - } - - private func makeKeyDownEvent( - key: String, - modifiers: NSEvent.ModifierFlags, - keyCode: UInt16, - windowNumber: Int = 0 - ) -> NSEvent? { - NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: windowNumber, - context: nil, - characters: key, - charactersIgnoringModifiers: key, - isARepeat: false, - keyCode: keyCode - ) - } -} - -@MainActor -final class GhosttyPasteboardHelperTests: XCTestCase { - func testHTMLOnlyPasteboardExtractsPlainText() { - let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("

Hello world

", forType: .html) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testImageHTMLClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("", forType: .html) - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.red.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - pasteboard.setData(pngData, forType: .png) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".png")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testImageHTMLClipboardWithVisibleTextPrefersText() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("

Hello

", forType: .html) - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.blue.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - pasteboard.setData(pngData, forType: .png) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testJPEGClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.green.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let jpegData = try XCTUnwrap( - bitmap.representation( - using: .jpeg, - properties: [.compressionFactor: 1.0] - ) - ) - pasteboard.setData( - jpegData, - forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier) - ) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".jpeg")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.orange.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let attachment = NSTextAttachment() - attachment.image = image - let attributed = NSAttributedString(attachment: attachment) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".tiff")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) - pasteboard.clearContents() - - let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8)) - wrapper.preferredFilename = "note.txt" - - let attachment = NSTextAttachment(fileWrapper: wrapper) - let attributed = NSAttributedString(attachment: attachment) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testRTFDClipboardWithVisibleTextPrefersText() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.purple.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let attachment = NSTextAttachment() - attachment.image = image - - let attributed = NSMutableAttributedString(string: "Hello ") - attributed.append(NSAttributedString(attachment: attachment)) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } -} - -@MainActor -final class AppDelegateWindowContextRoutingTests: XCTestCase { - private func makeMainWindow(id: UUID) -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") - return window - } - - func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { - _ = NSApplication.shared - let app = AppDelegate() - - let windowAId = UUID() - let windowBId = UUID() - let windowA = makeMainWindow(id: windowAId) - let windowB = makeMainWindow(id: windowBId) - defer { - windowA.orderOut(nil) - windowB.orderOut(nil) - } - - let managerA = TabManager() - let managerB = TabManager() - app.registerMainWindow( - windowA, - windowId: windowAId, - tabManager: managerA, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - app.registerMainWindow( - windowB, - windowId: windowBId, - tabManager: managerB, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - - windowB.makeKeyAndOrderFront(nil) - _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) - XCTAssertTrue(app.tabManager === managerB) - - windowA.makeKeyAndOrderFront(nil) - let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) - XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") - XCTAssertTrue(app.tabManager === managerA) - } - - func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { - _ = NSApplication.shared - let app = AppDelegate() - - let windowAId = UUID() - let windowBId = UUID() - let windowA = makeMainWindow(id: windowAId) - let windowB = makeMainWindow(id: windowBId) - defer { - windowA.orderOut(nil) - windowB.orderOut(nil) - } - - let managerA = TabManager() - let managerB = TabManager() - app.registerMainWindow( - windowA, - windowId: windowAId, - tabManager: managerA, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - app.registerMainWindow( - windowB, - windowId: windowBId, - tabManager: managerB, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - - // Seed active manager and clear focus windows to force fallback routing. - windowA.makeKeyAndOrderFront(nil) - _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) - XCTAssertTrue(app.tabManager === managerA) - windowA.orderOut(nil) - windowB.orderOut(nil) - - let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) - XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") - XCTAssertTrue(app.tabManager === managerA) - } - - func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { - _ = NSApplication.shared - let app = AppDelegate() - - let windowId = UUID() - let window = makeMainWindow(id: windowId) - defer { window.orderOut(nil) } - - let manager = TabManager() - app.registerMainWindow( - window, - windowId: windowId, - tabManager: manager, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - - // SwiftUI can replace the NSWindow identifier string at runtime. - window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") - - let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) - XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") - XCTAssertTrue(app.tabManager === manager) - } - - func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() { - _ = NSApplication.shared - let app = AppDelegate() - - let windowAId = UUID() - let windowBId = UUID() - let windowA = makeMainWindow(id: windowAId) - let windowB = makeMainWindow(id: windowBId) - defer { - windowA.orderOut(nil) - windowB.orderOut(nil) - } - - let managerA = TabManager() - let managerB = TabManager() - app.registerMainWindow( - windowA, - windowId: windowAId, - tabManager: managerA, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - app.registerMainWindow( - windowB, - windowId: windowBId, - tabManager: managerB, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - - windowA.makeKeyAndOrderFront(nil) - _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) - XCTAssertTrue(app.tabManager === managerA) - - let originalSelectedA = managerA.selectedTabId - let originalSelectedB = managerB.selectedTabId - let originalTabCountB = managerB.tabs.count - - let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false) - - XCTAssertNotNil(createdWorkspaceId) - XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing") - XCTAssertEqual(managerA.selectedTabId, originalSelectedA) - XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab") - XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) - XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) - } - - func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { - _ = NSApplication.shared - let app = AppDelegate() - - let windowId = UUID() - let window = makeMainWindow(id: windowId) - defer { window.orderOut(nil) } - - let manager = TabManager() - app.registerMainWindow( - window, - windowId: windowId, - tabManager: manager, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState() - ) - - window.makeKeyAndOrderFront(nil) - _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) - - let defaults = UserDefaults.standard - let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) - defaults.set(true, forKey: WelcomeSettings.shownKey) - defer { - if let previousWelcomeShown { - defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) - } else { - defaults.removeObject(forKey: WelcomeSettings.shownKey) - } - } - - let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) - try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: rootDirectory) } - - let existingWorkspaceIds = Set(manager.tabs.map(\.id)) - - app.application( - NSApplication.shared, - open: [URL(fileURLWithPath: droppedDirectory.path)] - ) - - let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } - XCTAssertNotNil(createdWorkspace) - XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) - } -} - -@MainActor -final class AppDelegateLaunchServicesRegistrationTests: XCTestCase { - func testScheduleLaunchServicesRegistrationDefersRegisterWork() { - _ = NSApplication.shared - let app = AppDelegate() - - var scheduledWork: (@Sendable () -> Void)? - var registerCallCount = 0 - - app.scheduleLaunchServicesBundleRegistrationForTesting( - bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"), - scheduler: { work in - scheduledWork = work - }, - register: { _ in - registerCallCount += 1 - return noErr - } - ) - - XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path") - XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler") - - scheduledWork?() - - XCTAssertEqual(registerCallCount, 1) - } -} - -final class FocusFlashPatternTests: XCTestCase { - func testFocusFlashPatternMatchesTerminalDoublePulseShape() { - XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) - XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) - XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) - XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) - XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) - XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) - } - - func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { - let segments = FocusFlashPattern.segments - XCTAssertEqual(segments.count, 4) - - XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001) - XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001) - XCTAssertEqual(segments[0].curve, .easeOut) - - XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) - XCTAssertEqual(segments[1].curve, .easeIn) - - XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) - XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001) - XCTAssertEqual(segments[2].curve, .easeOut) - - XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) - XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) - XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) - XCTAssertEqual(segments[3].curve, .easeIn) - } -} - -@MainActor -final class CmuxWebViewContextMenuTests: XCTestCase { - private func makeRightMouseDownEvent() -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: .rightMouseDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create rightMouseDown event") - } - return event - } - - func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() { - _ = NSApplication.shared - let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration()) - let menu = NSMenu() - let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "") - openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink") - menu.addItem(openLinkItem) - menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: "")) - - var openedURL: URL? - webView.contextMenuLinkURLProvider = { _, _, completion in - completion(URL(string: "https://example.com/docs")!) - } - webView.contextMenuDefaultBrowserOpener = { url in - openedURL = url - return true - } - - webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) - - guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else { - XCTFail("Expected Open Link in Default Browser item in context menu") - return - } - guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else { - XCTFail("Expected Open Link item in context menu") - return - } - - XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1) - let defaultBrowserItem = menu.items[defaultBrowserItemIndex] - XCTAssertTrue(defaultBrowserItem.target === webView) - XCTAssertNotNil(defaultBrowserItem.action) - - let dispatched = NSApp.sendAction( - defaultBrowserItem.action!, - to: defaultBrowserItem.target, - from: defaultBrowserItem - ) - XCTAssertTrue(dispatched) - XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs") - } - - func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() { - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: "")) - menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: "")) - - webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) - - XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) - } - - func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let menu = NSMenu() - let originalTarget = NSObject() - let originalAction = NSSelectorFromString("downloadImageToDisk:") - let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") - downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") - downloadItem.target = originalTarget - menu.addItem(downloadItem) - - webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) - - XCTAssertTrue(downloadItem.target === webView) - XCTAssertNotNil(downloadItem.action) - XCTAssertNotEqual(downloadItem.action, originalAction) - } - - func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let menu = NSMenu() - let originalTarget = NSObject() - let originalAction = NSSelectorFromString("downloadLinkToDisk:") - let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") - downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") - downloadItem.target = originalTarget - menu.addItem(downloadItem) - - webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) - - XCTAssertTrue(downloadItem.target === webView) - XCTAssertNotNil(downloadItem.action) - XCTAssertNotEqual(downloadItem.action, originalAction) - } -} - -final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { - private func makeIsolatedDefaults() -> UserDefaults { - let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Failed to create defaults suite") - } - defaults.removePersistentDomain(forName: suiteName) - addTeardownBlock { - defaults.removePersistentDomain(forName: suiteName) - } - return defaults - } - - func testIconCatalogIncludesExpandedChoices() { - XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) - XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) - XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) - XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) - } - - func testIconOptionFallsBackToDefaultForUnknownRawValue() { - let defaults = makeIsolatedDefaults() - defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) - - XCTAssertEqual( - BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), - BrowserDevToolsButtonDebugSettings.defaultIcon - ) - } - - func testColorOptionFallsBackToDefaultForUnknownRawValue() { - let defaults = makeIsolatedDefaults() - defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) - - XCTAssertEqual( - BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), - BrowserDevToolsButtonDebugSettings.defaultColor - ) - } - - func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() { - let defaults = makeIsolatedDefaults() - defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key) - - XCTAssertEqual( - BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), - BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - ) - } - - func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() { - let defaults = makeIsolatedDefaults() - defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) - - XCTAssertEqual( - BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), - BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - ) - } - - func testBrowserProfilePopoverPaddingDefaultsWhenUnset() { - let defaults = makeIsolatedDefaults() - defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) - defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) - - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - ) - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultVerticalPadding - ) - } - - func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() { - let defaults = makeIsolatedDefaults() - defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) - defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) - - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - ) - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultVerticalPadding - ) - } - - func testCopyPayloadUsesPersistedValues() { - let defaults = makeIsolatedDefaults() - defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) - defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) - - let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) - XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) - XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) - } -} - -final class BrowserThemeSettingsTests: XCTestCase { - private func makeIsolatedDefaults() -> UserDefaults { - let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Failed to create defaults suite") - } - defaults.removePersistentDomain(forName: suiteName) - addTeardownBlock { - defaults.removePersistentDomain(forName: suiteName) - } - return defaults - } - - func testDefaultsMatchConfiguredFallbacks() { - let defaults = makeIsolatedDefaults() - XCTAssertEqual( - BrowserThemeSettings.mode(defaults: defaults), - BrowserThemeSettings.defaultMode - ) - } - - func testModeReadsPersistedValue() { - let defaults = makeIsolatedDefaults() - defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey) - XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) - - defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey) - XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light) - } - - func testModeMigratesLegacyForcedDarkModeFlag() { - let defaults = makeIsolatedDefaults() - defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) - XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) - XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue) - - let otherDefaults = makeIsolatedDefaults() - otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) - XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system) - XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue) - } -} - -final class BrowserPanelChromeBackgroundColorTests: XCTestCase { - func testLightModeUsesThemeBackgroundColor() { - assertResolvedColorMatchesTheme(for: .light) - } - - func testDarkModeUsesThemeBackgroundColor() { - assertResolvedColorMatchesTheme(for: .dark) - } - - private func assertResolvedColorMatchesTheme( - for colorScheme: ColorScheme, - file: StaticString = #filePath, - line: UInt = #line - ) { - let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) - - guard - let actual = resolvedBrowserChromeBackgroundColor( - for: colorScheme, - themeBackgroundColor: themeBackground - ).usingColorSpace(.sRGB), - let expected = themeBackground.usingColorSpace(.sRGB) - else { - XCTFail("Expected sRGB-convertible colors", file: file, line: line) - return - } - - XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) - } -} - -final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { - func testLightModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) - } - - func testDarkModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) - } - - private func assertResolvedColorMatchesExpectedBlend( - for colorScheme: ColorScheme, - darkenMix: CGFloat, - file: StaticString = #filePath, - line: UInt = #line - ) { - let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground - - guard - let actual = resolvedBrowserOmnibarPillBackgroundColor( - for: colorScheme, - themeBackgroundColor: themeBackground - ).usingColorSpace(.sRGB), - let expectedSRGB = expected.usingColorSpace(.sRGB), - let themeSRGB = themeBackground.usingColorSpace(.sRGB) - else { - XCTFail("Expected sRGB-convertible colors", file: file, line: line) - return - } - - XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) - XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) - } -} - -final class SidebarActiveForegroundColorTests: XCTestCase { - func testLightAppearanceUsesBlackWithRequestedOpacity() { - guard let lightAppearance = NSAppearance(named: .aqua), - let color = sidebarActiveForegroundNSColor( - opacity: 0.8, - appAppearance: lightAppearance - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) - } - - func testDarkAppearanceUsesWhiteWithRequestedOpacity() { - guard let darkAppearance = NSAppearance(named: .darkAqua), - let color = sidebarActiveForegroundNSColor( - opacity: 0.65, - appAppearance: darkAppearance - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) - } -} - -final class SidebarSelectedWorkspaceColorTests: XCTestCase { - func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() { - guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) - } - - func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() { - guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) - } - - func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { - guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) - } -} -final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { - func testSafariDefaultShortcutForToggleDeveloperTools() { - let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut - XCTAssertEqual(shortcut.key, "i") - XCTAssertTrue(shortcut.command) - XCTAssertTrue(shortcut.option) - XCTAssertFalse(shortcut.shift) - XCTAssertFalse(shortcut.control) - } - - func testSafariDefaultShortcutForShowJavaScriptConsole() { - let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut - XCTAssertEqual(shortcut.key, "c") - XCTAssertTrue(shortcut.command) - XCTAssertTrue(shortcut.option) - XCTAssertFalse(shortcut.shift) - XCTAssertFalse(shortcut.control) - } -} - -final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { - func testRenameTabShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") - XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") - - let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut - XCTAssertEqual(shortcut.key, "r") - XCTAssertTrue(shortcut.command) - XCTAssertFalse(shortcut.shift) - XCTAssertFalse(shortcut.option) - XCTAssertFalse(shortcut.control) - } - - func testCloseWindowShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") - XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") - - let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut - XCTAssertEqual(shortcut.key, "w") - XCTAssertTrue(shortcut.command) - XCTAssertFalse(shortcut.shift) - XCTAssertFalse(shortcut.option) - XCTAssertTrue(shortcut.control) - } - - func testRenameWorkspaceShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") - XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") - - let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut - XCTAssertEqual(shortcut.key, "r") - XCTAssertTrue(shortcut.command) - XCTAssertTrue(shortcut.shift) - XCTAssertFalse(shortcut.option) - XCTAssertFalse(shortcut.control) - } - - func testRenameWorkspaceShortcutConvertsToMenuShortcut() { - let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut - XCTAssertNotNil(shortcut.keyEquivalent) - XCTAssertTrue(shortcut.eventModifiers.contains(.command)) - XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) - XCTAssertFalse(shortcut.eventModifiers.contains(.option)) - XCTAssertFalse(shortcut.eventModifiers.contains(.control)) - } - - func testCloseWorkspaceShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace") - XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace") - - let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut - XCTAssertEqual(shortcut.key, "w") - XCTAssertTrue(shortcut.command) - XCTAssertTrue(shortcut.shift) - XCTAssertFalse(shortcut.option) - XCTAssertFalse(shortcut.control) - } - - func testCloseWorkspaceShortcutConvertsToMenuShortcut() { - let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut - XCTAssertNotNil(shortcut.keyEquivalent) - XCTAssertTrue(shortcut.eventModifiers.contains(.command)) - XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) - XCTAssertFalse(shortcut.eventModifiers.contains(.option)) - XCTAssertFalse(shortcut.eventModifiers.contains(.control)) - } - - func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace") - XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace") - XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab") - XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab") - - let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut - XCTAssertEqual(nextShortcut.key, "]") - XCTAssertTrue(nextShortcut.command) - XCTAssertFalse(nextShortcut.shift) - XCTAssertFalse(nextShortcut.option) - XCTAssertTrue(nextShortcut.control) - - let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut - XCTAssertEqual(prevShortcut.key, "[") - XCTAssertTrue(prevShortcut.command) - XCTAssertFalse(prevShortcut.shift) - XCTAssertFalse(prevShortcut.option) - XCTAssertTrue(prevShortcut.control) - } - - func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() { - let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut - XCTAssertNotNil(nextShortcut.keyEquivalent) - XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]") - XCTAssertTrue(nextShortcut.eventModifiers.contains(.command)) - XCTAssertTrue(nextShortcut.eventModifiers.contains(.control)) - - let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut - XCTAssertNotNil(prevShortcut.keyEquivalent) - XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[") - XCTAssertTrue(prevShortcut.eventModifiers.contains(.command)) - XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) - } - - func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() { - XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode") - XCTAssertEqual( - KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey, - "shortcut.toggleTerminalCopyMode" - ) - - let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut - XCTAssertEqual(shortcut.key, "m") - XCTAssertTrue(shortcut.command) - XCTAssertTrue(shortcut.shift) - XCTAssertFalse(shortcut.option) - XCTAssertFalse(shortcut.control) - } - - func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { - XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) - XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) - XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) - XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) - XCTAssertEqual( - StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent, - "\t" - ) - } - - func testShortcutDefaultsKeysRemainUnique() { - let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) - XCTAssertEqual(Set(keys).count, keys.count) - } -} - -final class TerminalKeyboardCopyModeActionTests: XCTestCase { - func testCopyModeBypassAllowsOnlyCommandShortcuts() { - XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command])) - XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift])) - XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option])) - XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option])) - XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift])) - XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control])) - } - - func testJKWithoutSelectionScrollByLine() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 38, - charactersIgnoringModifiers: "j", - modifierFlags: [], - hasSelection: false - ), - .scrollLines(1) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 40, - charactersIgnoringModifiers: "k", - modifierFlags: [], - hasSelection: false - ), - .scrollLines(-1) - ) - } - - func testCapsLockDoesNotBlockLetterMappings() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 38, - charactersIgnoringModifiers: "j", - modifierFlags: [.capsLock], - hasSelection: false - ), - .scrollLines(1) - ) - } - - func testJKWithSelectionAdjustSelection() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 38, - charactersIgnoringModifiers: "j", - modifierFlags: [], - hasSelection: true - ), - .adjustSelection(.down) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 40, - charactersIgnoringModifiers: "k", - modifierFlags: [], - hasSelection: true - ), - .adjustSelection(.up) - ) - } - - func testControlPagingSupportsPrintableAndControlCharacters() { - // Ctrl+U = half-page up (vim standard). - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{15}", - modifierFlags: [.control], - hasSelection: false - ), - .scrollHalfPage(-1) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{04}", - modifierFlags: [.control], - hasSelection: true - ), - .adjustSelection(.pageDown) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{02}", - modifierFlags: [.control], - hasSelection: false - ), - .scrollPage(-1) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{06}", - modifierFlags: [.control], - hasSelection: true - ), - .adjustSelection(.pageDown) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{19}", - modifierFlags: [.control], - hasSelection: false - ), - .scrollLines(-1) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 0, - charactersIgnoringModifiers: "\u{05}", - modifierFlags: [.control], - hasSelection: true - ), - .adjustSelection(.down) - ) - } - - func testVGYMapping() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 9, - charactersIgnoringModifiers: "v", - modifierFlags: [], - hasSelection: false - ), - .startSelection - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 9, - charactersIgnoringModifiers: "v", - modifierFlags: [], - hasSelection: true - ), - .clearSelection - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 16, - charactersIgnoringModifiers: "y", - modifierFlags: [], - hasSelection: true - ), - .copyAndExit - ) - } - - func testGAndShiftGMapping() { - // Bare "g" is a prefix key (gg), not an immediate action. - XCTAssertNil( - terminalKeyboardCopyModeAction( - keyCode: 5, - charactersIgnoringModifiers: "g", - modifierFlags: [], - hasSelection: false - ) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 5, - charactersIgnoringModifiers: "g", - modifierFlags: [.shift], - hasSelection: false - ), - .scrollToBottom - ) - } - - func testLineBoundaryPromptAndSearchMappings() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 29, - charactersIgnoringModifiers: "0", - modifierFlags: [], - hasSelection: true - ), - .adjustSelection(.beginningOfLine) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 20, - charactersIgnoringModifiers: "^", - modifierFlags: [.shift], - hasSelection: true - ), - .adjustSelection(.beginningOfLine) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 21, - charactersIgnoringModifiers: "4", - modifierFlags: [.shift], - hasSelection: true - ), - .adjustSelection(.endOfLine) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 33, - charactersIgnoringModifiers: "[", - modifierFlags: [.shift], - hasSelection: false - ), - .jumpToPrompt(-1) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 30, - charactersIgnoringModifiers: "]", - modifierFlags: [.shift], - hasSelection: false - ), - .jumpToPrompt(1) - ) - XCTAssertNil( - terminalKeyboardCopyModeAction( - keyCode: 21, - charactersIgnoringModifiers: "4", - modifierFlags: [], - hasSelection: true - ) - ) - XCTAssertNil( - terminalKeyboardCopyModeAction( - keyCode: 33, - charactersIgnoringModifiers: "[", - modifierFlags: [], - hasSelection: false - ) - ) - XCTAssertNil( - terminalKeyboardCopyModeAction( - keyCode: 30, - charactersIgnoringModifiers: "]", - modifierFlags: [], - hasSelection: false - ) - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 44, - charactersIgnoringModifiers: "/", - modifierFlags: [], - hasSelection: false - ), - .startSearch - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 45, - charactersIgnoringModifiers: "n", - modifierFlags: [], - hasSelection: false - ), - .searchNext - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 45, - charactersIgnoringModifiers: "n", - modifierFlags: [.shift], - hasSelection: false - ), - .searchPrevious - ) - } - - func testShiftVMatchesVisualToggleBehavior() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 9, - charactersIgnoringModifiers: "v", - modifierFlags: [.shift], - hasSelection: false - ), - .startSelection - ) - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 9, - charactersIgnoringModifiers: "v", - modifierFlags: [.shift], - hasSelection: true - ), - .clearSelection - ) - } - - func testEscapeAlwaysExits() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 53, - charactersIgnoringModifiers: "", - modifierFlags: [], - hasSelection: false - ), - .exit - ) - } - - func testQAlwaysExits() { - XCTAssertEqual( - terminalKeyboardCopyModeAction( - keyCode: 12, // kVK_ANSI_Q - charactersIgnoringModifiers: "q", - modifierFlags: [], - hasSelection: false - ), - .exit - ) - } -} - -final class TerminalKeyboardCopyModeResolveTests: XCTestCase { - private func resolve( - _ keyCode: UInt16, - chars: String, - modifiers: NSEvent.ModifierFlags = [], - hasSelection: Bool, - state: inout TerminalKeyboardCopyModeInputState - ) -> TerminalKeyboardCopyModeResolution { - terminalKeyboardCopyModeResolve( - keyCode: keyCode, - charactersIgnoringModifiers: chars, - modifierFlags: modifiers, - hasSelection: hasSelection, - state: &state - ) - } - - func testCountPrefixAppliesToMotion() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3)) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - func testZeroAppendsCountOrActsAsMotion() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20)) - - var selectionState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(29, chars: "0", hasSelection: true, state: &selectionState), - .perform(.adjustSelection(.beginningOfLine), count: 1) - ) - } - - func testYankLineOperatorSupportsYYAndYWithCounts() { - var yyState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume) - XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1)) - - var countedState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume) - XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume) - XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4)) - - var shiftYState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume) - XCTAssertEqual( - resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState), - .perform(.copyLineAndExit, count: 3) - ) - } - - func testPendingYankLineDoesNotSwallowNextCommand() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - func testSearchAndPromptMotionsUseCounts() { - var promptState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume) - XCTAssertEqual( - resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState), - .perform(.jumpToPrompt(1), count: 3) - ) - - var searchState = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume) - XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2)) - } - - func testInvalidKeyClearsPendingState() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - // MARK: - gg (scroll to top via two-key sequence) - - func testGGScrollsToTop() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1)) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - func testGGWithSelectionAdjustsToHome() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume) - XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1)) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - func testCountedGG() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5)) - } - - func testPendingGCancelledByOtherKey() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) - XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - func testShiftGStillWorksImmediately() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state), - .perform(.scrollToBottom, count: 1) - ) - XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) - } - - // MARK: - Ctrl+U/D half-page scroll - - func testCtrlUHalfPage() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state), - .perform(.scrollHalfPage(-1), count: 1) - ) - } - - func testCtrlDHalfPage() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state), - .perform(.scrollHalfPage(1), count: 1) - ) - } - - func testCtrlBFullPage() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state), - .perform(.scrollPage(-1), count: 1) - ) - } - - func testCtrlFFullPage() { - var state = TerminalKeyboardCopyModeInputState() - XCTAssertEqual( - resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state), - .perform(.scrollPage(1), count: 1) - ) - } -} - -final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase { - func testInitialViewportRowUsesImePointBaseline() { - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 24, - imeCellHeight: 24 - ), - 0 - ) - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 240, - imeCellHeight: 24 - ), - 9 - ) - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 48, - imeCellHeight: 24, - topPadding: 24 - ), - 0 - ) - } - - func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() { - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 0, - imeCellHeight: 24 - ), - 0 - ) - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 9999, - imeCellHeight: 24 - ), - 23 - ) - XCTAssertEqual( - terminalKeyboardCopyModeInitialViewportRow( - rows: 24, - imePointY: 123, - imeCellHeight: 0 - ), - 23 - ) - } -} - -@MainActor -final class BrowserDeveloperToolsConfigurationTests: XCTestCase { - func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { - let panel = BrowserPanel(workspaceId: UUID()) - let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool - XCTAssertEqual(developerExtras, true) - - if #available(macOS 13.3, *) { - XCTAssertTrue(panel.webView.isInspectable) - } - } - - func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() { - let panel = BrowserPanel(workspaceId: UUID()) - let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) - let updatedOpacity = 0.57 - - NotificationCenter.default.post( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: [ - GhosttyNotificationKey.backgroundColor: updatedColor, - GhosttyNotificationKey.backgroundOpacity: updatedOpacity - ] - ) - - guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), - let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible under-page background colors") - return - } - - XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) - XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) - XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) - XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) - } - - func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() { - let panel = BrowserPanel(workspaceId: UUID()) - - XCTAssertEqual(panel.displayTitle, "New tab") - XCTAssertFalse(panel.shouldRenderWebView) - XCTAssertTrue(panel.isShowingNewTabPage) - XCTAssertNil(panel.webView.url) - XCTAssertNil(panel.currentURL) - } - - func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() { - let panel = BrowserPanel(workspaceId: UUID()) - - XCTAssertTrue(panel.isShowingNewTabPage) - panel.navigate(to: URL(string: "https://example.com")!) - XCTAssertFalse(panel.isShowingNewTabPage) - } - - func testBrowserPanelThemeModeUpdatesWebViewAppearance() { - let panel = BrowserPanel(workspaceId: UUID()) - - panel.setBrowserThemeMode(.dark) - XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua) - - panel.setBrowserThemeMode(.light) - XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua) - - panel.setBrowserThemeMode(.system) - XCTAssertNil(panel.webView.appearance) - } - - func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() { - let panel = BrowserPanel(workspaceId: UUID()) - let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) - - NotificationCenter.default.post( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: [ - GhosttyNotificationKey.backgroundColor: updatedColor, - GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), - ] - ) - - guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), - let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible under-page background colors") - return - } - - XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) - XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) - XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) - XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) - } -} - -final class GhosttyBackgroundThemeTests: XCTestCase { - func testColorClampsOpacity() { - let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) - - let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) - XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) - - let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) - XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) - } - - func testColorFromNotificationUsesBackgroundAndOpacity() { - let fallbackColor = NSColor.black - let fallbackOpacity = 1.0 - let notification = Notification( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: [ - GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), - GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), - ] - ) - - let actual = GhosttyBackgroundTheme.color( - from: notification, - fallbackColor: fallbackColor, - fallbackOpacity: fallbackOpacity - ) - guard let srgb = actual.usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) - XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) - XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) - XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) - } - - func testColorFromNotificationFallsBackWhenPayloadMissing() { - let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) - let fallbackOpacity = 0.42 - let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) - - let actual = GhosttyBackgroundTheme.color( - from: notification, - fallbackColor: fallbackColor, - fallbackOpacity: fallbackOpacity - ) - guard let srgb = actual.usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) - XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) - XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) - XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) - } -} - -@MainActor -final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase { - private final class BrowserInsecureHTTPAlertSpy: NSAlert { - private(set) var beginSheetModalCallCount = 0 - private(set) var runModalCallCount = 0 - var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn - - override func beginSheetModal( - for sheetWindow: NSWindow, - completionHandler handler: ((NSApplication.ModalResponse) -> Void)? - ) { - beginSheetModalCallCount += 1 - handler?(nextResponse) - } - - override func runModal() -> NSApplication.ModalResponse { - runModalCallCount += 1 - return nextResponse - } - } - - func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() { - let panel = BrowserPanel(workspaceId: UUID()) - defer { panel.resetInsecureHTTPAlertHooksForTesting() } - - let alertSpy = BrowserInsecureHTTPAlertSpy() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - - panel.configureInsecureHTTPAlertHooksForTesting( - alertFactory: { alertSpy }, - windowProvider: { window } - ) - panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) - - XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) - XCTAssertEqual(alertSpy.runModalCallCount, 0) - } - - func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() { - let panel = BrowserPanel(workspaceId: UUID()) - defer { panel.resetInsecureHTTPAlertHooksForTesting() } - - let alertSpy = BrowserInsecureHTTPAlertSpy() - panel.configureInsecureHTTPAlertHooksForTesting( - alertFactory: { alertSpy }, - windowProvider: { nil } - ) - panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!) - - XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) - XCTAssertEqual(alertSpy.runModalCallCount, 1) - } -} - -final class BrowserNavigationNewTabDecisionTests: XCTestCase { - func testLinkActivatedCmdClickOpensInNewTab() { - XCTAssertTrue( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [.command], - buttonNumber: 0 - ) - ) - } - - func testLinkActivatedMiddleClickOpensInNewTab() { - XCTAssertTrue( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 2 - ) - ) - } - - func testLinkActivatedPlainLeftClickStaysInCurrentTab() { - XCTAssertFalse( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testOtherNavigationMiddleClickOpensInNewTab() { - XCTAssertTrue( - browserNavigationShouldOpenInNewTab( - navigationType: .other, - modifierFlags: [], - buttonNumber: 2 - ) - ) - } - - func testOtherNavigationLeftClickStaysInCurrentTab() { - XCTAssertFalse( - browserNavigationShouldOpenInNewTab( - navigationType: .other, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() { - XCTAssertFalse( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 4, - hasRecentMiddleClickIntent: false - ) - ) - } - - func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() { - XCTAssertTrue( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 4, - hasRecentMiddleClickIntent: true - ) - ) - } - - func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() { - XCTAssertTrue( - browserNavigationShouldOpenInNewTab( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 0, - currentEventType: .otherMouseUp, - currentEventButtonNumber: 2 - ) - ) - } - - func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() { - XCTAssertFalse( - browserNavigationShouldOpenInNewTab( - navigationType: .reload, - modifierFlags: [], - buttonNumber: 0, - currentEventType: .otherMouseUp, - currentEventButtonNumber: 2 - ) - ) - } - - func testNonLinkNavigationNeverForcesNewTab() { - XCTAssertFalse( - browserNavigationShouldOpenInNewTab( - navigationType: .reload, - modifierFlags: [.command], - buttonNumber: 2 - ) - ) - } -} - -final class BrowserPopupDecisionTests: XCTestCase { - func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testOtherNavigationPlainLeftClickCreatesPopup() { - XCTAssertTrue( - browserNavigationShouldCreatePopup( - navigationType: .other, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testOtherNavigationMiddleClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .other, - modifierFlags: [], - buttonNumber: 2 - ) - ) - } - - func testLinkActivatedCmdClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .linkActivated, - modifierFlags: [.command], - buttonNumber: 0 - ) - ) - } -} - -final class BrowserNilTargetFallbackDecisionTests: XCTestCase { - func testOtherNavigationDoesNotFallbackToNewTab() { - XCTAssertFalse( - browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: .other - ) - ) - } - - func testLinkActivatedNavigationFallsBackToNewTab() { - XCTAssertTrue( - browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: .linkActivated - ) - ) - } -} - -final class BrowserPopupContentRectTests: XCTestCase { - func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { - let rect = browserPopupContentRect( - requestedWidth: 400, - requestedHeight: 300, - requestedX: 150, - requestedTopY: 120, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) - XCTAssertEqual(rect.width, 400, accuracy: 0.01) - XCTAssertEqual(rect.height, 300, accuracy: 0.01) - } - - func testExplicitCoordinatesClampToVisibleFrame() { - let rect = browserPopupContentRect( - requestedWidth: 1400, - requestedHeight: 1200, - requestedX: 900, - requestedTopY: -25, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) - XCTAssertEqual(rect.width, 1000, accuracy: 0.01) - XCTAssertEqual(rect.height, 800, accuracy: 0.01) - } - - func testMissingCoordinatesCentersPopup() { - let rect = browserPopupContentRect( - requestedWidth: 300, - requestedHeight: 200, - requestedX: nil, - requestedTopY: nil, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) - XCTAssertEqual(rect.width, 300, accuracy: 0.01) - XCTAssertEqual(rect.height, 200, accuracy: 0.01) - } -} - -@MainActor -final class BrowserJavaScriptDialogDelegateTests: XCTestCase { - func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { - let panel = BrowserPanel(workspaceId: UUID()) - guard let uiDelegate = panel.webView.uiDelegate as? NSObject else { - XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject") - return - } - - XCTAssertTrue( - uiDelegate.responds( - to: #selector( - WKUIDelegate.webView( - _:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler: - ) - ) - ), - "Browser UI delegate must implement JavaScript alert handling" - ) - XCTAssertTrue( - uiDelegate.responds( - to: #selector( - WKUIDelegate.webView( - _:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler: - ) - ) - ), - "Browser UI delegate must implement JavaScript confirm handling" - ) - XCTAssertTrue( - uiDelegate.responds( - to: #selector( - WKUIDelegate.webView( - _:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler: - ) - ) - ), - "Browser UI delegate must implement JavaScript prompt handling" - ) - } -} - -@MainActor -final class BrowserSessionHistoryRestoreTests: XCTestCase { - func testSessionNavigationHistorySnapshotUsesRestoredStacks() { - let panel = BrowserPanel(workspaceId: UUID()) - - panel.restoreSessionNavigationHistory( - backHistoryURLStrings: [ - "https://example.com/a", - "https://example.com/b" - ], - forwardHistoryURLStrings: [ - "https://example.com/d" - ], - currentURLString: "https://example.com/c" - ) - - XCTAssertTrue(panel.canGoBack) - XCTAssertTrue(panel.canGoForward) - - let snapshot = panel.sessionNavigationHistorySnapshot() - XCTAssertEqual( - snapshot.backHistoryURLStrings, - ["https://example.com/a", "https://example.com/b"] - ) - XCTAssertEqual( - snapshot.forwardHistoryURLStrings, - ["https://example.com/d"] - ) - } - - func testSessionNavigationHistoryBackAndForwardUpdateStacks() { - let panel = BrowserPanel(workspaceId: UUID()) - - panel.restoreSessionNavigationHistory( - backHistoryURLStrings: [ - "https://example.com/a", - "https://example.com/b" - ], - forwardHistoryURLStrings: [ - "https://example.com/d" - ], - currentURLString: "https://example.com/c" - ) - - panel.goBack() - let afterBack = panel.sessionNavigationHistorySnapshot() - XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"]) - XCTAssertEqual( - afterBack.forwardHistoryURLStrings, - ["https://example.com/c", "https://example.com/d"] - ) - XCTAssertTrue(panel.canGoBack) - XCTAssertTrue(panel.canGoForward) - - panel.goForward() - let afterForward = panel.sessionNavigationHistorySnapshot() - XCTAssertEqual( - afterForward.backHistoryURLStrings, - ["https://example.com/a", "https://example.com/b"] - ) - XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"]) - XCTAssertTrue(panel.canGoBack) - XCTAssertTrue(panel.canGoForward) - } - - func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() { - let panel = BrowserPanel( - workspaceId: UUID(), - initialURL: URL(string: "https://example.com") - ) - let oldWebView = panel.webView - let oldInstanceID = panel.webViewInstanceID - - panel.debugSimulateWebContentProcessTermination() - - XCTAssertFalse(panel.webView === oldWebView) - XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID) - XCTAssertNotNil(panel.webView.navigationDelegate) - XCTAssertNotNil(panel.webView.uiDelegate) - } - - func testWebViewReplacementPreservesEmptyNewTabRenderState() { - let panel = BrowserPanel(workspaceId: UUID()) - XCTAssertFalse(panel.shouldRenderWebView) - - panel.debugSimulateWebContentProcessTermination() - - XCTAssertFalse(panel.shouldRenderWebView) - } - - func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { - let workspace = Workspace() - let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) - let browser = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - url: URL(string: "https://example.com"), - focus: false - ) - ) - - browser.restoreSessionNavigationHistory( - backHistoryURLStrings: ["https://example.com/prev"], - forwardHistoryURLStrings: ["https://example.com/next"], - currentURLString: "https://example.com/current" - ) - browser.startFind() - - workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208") - workspace.metadataBlocks["notes"] = SidebarMetadataBlock( - key: "notes", - markdown: "test", - priority: 0, - timestamp: Date() - ) - workspace.progress = SidebarProgressState(value: 0.5, label: "Loading") - workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false) - workspace.updatePanelPullRequest( - panelId: contextPanelId, - number: 1208, - label: "PR", - url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), - status: .open - ) - workspace.logEntries.append( - SidebarLogEntry( - message: "Issue #1208", - level: .info, - source: "test", - timestamp: Date() - ) - ) - workspace.surfaceListeningPorts[contextPanelId] = [3000] - workspace.recomputeListeningPorts() - - XCTAssertTrue(browser.shouldRenderWebView) - XCTAssertNotNil(browser.preferredURLStringForOmnibar()) - XCTAssertTrue(browser.canGoBack) - XCTAssertTrue(browser.canGoForward) - XCTAssertNotNil(browser.searchState) - XCTAssertFalse(workspace.statusEntries.isEmpty) - XCTAssertFalse(workspace.logEntries.isEmpty) - XCTAssertFalse(workspace.metadataBlocks.isEmpty) - XCTAssertNotNil(workspace.progress) - XCTAssertNotNil(workspace.gitBranch) - XCTAssertNotNil(workspace.pullRequest) - XCTAssertEqual(workspace.listeningPorts, [3000]) - - let priorWebView = browser.webView - let priorInstanceID = browser.webViewInstanceID - workspace.resetSidebarContext(reason: "test") - - XCTAssertTrue(workspace.statusEntries.isEmpty) - XCTAssertTrue(workspace.logEntries.isEmpty) - XCTAssertTrue(workspace.metadataBlocks.isEmpty) - XCTAssertNil(workspace.progress) - XCTAssertNil(workspace.gitBranch) - XCTAssertTrue(workspace.panelGitBranches.isEmpty) - XCTAssertNil(workspace.pullRequest) - XCTAssertTrue(workspace.panelPullRequests.isEmpty) - XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) - XCTAssertTrue(workspace.listeningPorts.isEmpty) - XCTAssertFalse(browser.shouldRenderWebView) - XCTAssertNil(browser.preferredURLStringForOmnibar()) - XCTAssertFalse(browser.canGoBack) - XCTAssertFalse(browser.canGoForward) - XCTAssertNil(browser.searchState) - XCTAssertFalse(browser.webView === priorWebView) - XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID) - } - -} - -@MainActor -final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { - private final class WKInspectorProbeView: NSView { - override var acceptsFirstResponder: Bool { true } - } - - private final class FakeInspector: NSObject { - enum HideBehavior { - case unsupported - case noEffect - case hides - } - - private(set) var attachCount = 0 - private(set) var showCount = 0 - private(set) var hideCount = 0 - private(set) var closeCount = 0 - private let hideBehavior: HideBehavior - private var visible = false - private var attached = false - - init(hideBehavior: HideBehavior = .unsupported) { - self.hideBehavior = hideBehavior - super.init() - } - - override func responds(to aSelector: Selector!) -> Bool { - guard NSStringFromSelector(aSelector) == "hide" else { - return super.responds(to: aSelector) - } - return hideBehavior != .unsupported - } - - @objc func isVisible() -> Bool { - visible - } - - @objc func isAttached() -> Bool { - attached - } - - @objc func attach() { - attachCount += 1 - attached = true - show() - } - - @objc func show() { - showCount += 1 - visible = true - } - - @objc func hide() { - hideCount += 1 - guard hideBehavior == .hides else { return } - visible = false - } - - @objc func close() { - closeCount += 1 - visible = false - attached = false - } - } - - override class func setUp() { - super.setUp() - installCmuxUnitTestInspectorOverride() - } - - private func makePanelWithInspector( - hideBehavior: FakeInspector.HideBehavior = .unsupported - ) -> (BrowserPanel, FakeInspector) { - let panel = BrowserPanel(workspaceId: UUID()) - let inspector = FakeInspector(hideBehavior: hideBehavior) - panel.webView.cmuxSetUnitTestInspector(inspector) - return (panel, inspector) - } - - private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? { - if let host = root as? WebViewRepresentable.HostContainerView { - return host - } - for subview in root.subviews { - if let host = findHostContainerView(in: subview) { - return host - } - } - return nil - } - - private func waitForDeveloperToolsTransitions() { - RunLoop.current.run(until: Date().addingTimeInterval(0.5)) - } - - private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { - if let slot = root as? WindowBrowserSlotView { - return slot - } - for subview in root.subviews { - if let slot = findWindowBrowserSlotView(in: subview) { - return slot - } - } - return nil - } - - func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 1) - - // Simulate WebKit closing inspector during detach/reattach churn. - inspector.close() - XCTAssertFalse(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.closeCount, 1) - - panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 2) - } - - func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertEqual(inspector.showCount, 1) - - // Simulate user closing inspector before detach. - inspector.close() - panel.syncDeveloperToolsPreferenceFromInspector() - - panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertFalse(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 1) - } - - func testSyncCanPreserveVisibleIntentDuringDetachChurn() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertEqual(inspector.showCount, 1) - - // Simulate a transient close caused by view detach, not user intent. - inspector.close() - panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) - panel.restoreDeveloperToolsAfterAttachIfNeeded() - - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 2) - } - - func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 1) - XCTAssertEqual(inspector.closeCount, 0) - - panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") - panel.restoreDeveloperToolsAfterAttachIfNeeded() - - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.closeCount, 0) - XCTAssertEqual(inspector.showCount, 1) - - // The force-refresh request should be one-shot. - panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertEqual(inspector.closeCount, 0) - XCTAssertEqual(inspector.showCount, 1) - } - - func testRefreshRequestTracksPendingStateUntilRestoreRuns() { - let (panel, _) = makePanelWithInspector() - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) - - panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") - XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach()) - - panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) - } - - func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.toggleDeveloperTools()) - XCTAssertTrue(panel.toggleDeveloperTools()) - XCTAssertTrue(panel.toggleDeveloperTools()) - XCTAssertEqual(inspector.showCount, 1) - XCTAssertEqual(inspector.closeCount, 0) - - waitForDeveloperToolsTransitions() - - XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 1) - XCTAssertEqual(inspector.closeCount, 0) - } - - func testRapidToggleQueuesHideAfterOpenTransitionSettles() { - let (panel, inspector) = makePanelWithInspector() - - XCTAssertTrue(panel.toggleDeveloperTools()) - XCTAssertTrue(panel.toggleDeveloperTools()) - XCTAssertEqual(inspector.showCount, 1) - XCTAssertEqual(inspector.closeCount, 0) - - waitForDeveloperToolsTransitions() - - XCTAssertFalse(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.showCount, 1) - XCTAssertEqual(inspector.closeCount, 1) - } - - func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() { - let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect) - - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertTrue(panel.isDeveloperToolsVisible()) - - XCTAssertTrue(panel.toggleDeveloperTools()) - - XCTAssertEqual(inspector.hideCount, 1) - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertFalse(panel.isDeveloperToolsVisible()) - } - - func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { - let (panel, _) = makePanelWithInspector() - - XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - XCTAssertTrue(panel.showDeveloperTools()) - XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - XCTAssertTrue(panel.hideDeveloperTools()) - XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - } - - func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { - let (panel, _) = makePanelWithInspector() - let paneId = PaneID(id: UUID()) - XCTAssertTrue(panel.showDeveloperTools()) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140)) - window.contentView?.addSubview(anchor) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - XCTAssertNotNil(panel.webView.superview) - - let representable = WebViewRepresentable( - panel: panel, - paneId: paneId, - shouldAttachWebView: true, - useLocalInlineHosting: false, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0, - paneDropZone: nil, - searchOverlay: nil, - paneTopChromeHeight: 0 - ) - let coordinator = representable.makeCoordinator() - coordinator.webView = panel.webView - WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) - - XCTAssertNotNil(panel.webView.superview) - window.orderOut(nil) - } - - func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { - let (panel, _) = makePanelWithInspector() - let paneId = PaneID(id: UUID()) - XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150)) - window.contentView?.addSubview(anchor) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - XCTAssertNotNil(panel.webView.superview) - - let representable = WebViewRepresentable( - panel: panel, - paneId: paneId, - shouldAttachWebView: true, - useLocalInlineHosting: false, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0, - paneDropZone: nil, - searchOverlay: nil, - paneTopChromeHeight: 0 - ) - let coordinator = representable.makeCoordinator() - coordinator.webView = panel.webView - WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) - - XCTAssertNotNil(panel.webView.superview) - window.orderOut(nil) - } - - func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() { - let (panel, _) = makePanelWithInspector() - XCTAssertTrue(panel.showDeveloperTools()) - - let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) - panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height) - host.addSubview(panel.webView) - - let inspectorContainer = NSView( - frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height) - ) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - host.addSubview(inspectorContainer) - - XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - } - - func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() { - let (panel, _) = makePanelWithInspector() - XCTAssertTrue(panel.showDeveloperTools()) - - let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) - panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80) - host.addSubview(panel.webView) - - let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80)) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - host.addSubview(inspectorContainer) - - XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) - } - - func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() { - let (panel, _) = makePanelWithInspector() - XCTAssertTrue(panel.showDeveloperTools()) - - let paneId = PaneID(id: UUID()) - let representable = WebViewRepresentable( - panel: panel, - paneId: paneId, - shouldAttachWebView: false, - useLocalInlineHosting: true, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0, - paneDropZone: nil, - searchOverlay: nil, - paneTopChromeHeight: 0 - ) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let visibleHosting = NSHostingView(rootView: representable) - visibleHosting.frame = contentView.bounds - visibleHosting.autoresizingMask = [.width, .height] - contentView.addSubview(visibleHosting) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - visibleHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let visibleHost = findHostContainerView(in: visibleHosting) else { - XCTFail("Expected visible local host") - return - } - guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected visible local inline slot") - return - } - - let inspectorView = WKInspectorProbeView( - frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72) - ) - inspectorView.autoresizingMask = [.width] - visibleSlot.addSubview(inspectorView) - panel.webView.frame = NSRect( - x: 0, - y: inspectorView.frame.maxY, - width: visibleSlot.bounds.width, - height: visibleSlot.bounds.height - inspectorView.frame.height - ) - visibleSlot.layoutSubtreeIfNeeded() - - let detachedRoot = NSView(frame: visibleHosting.frame) - let offWindowHosting = NSHostingView(rootView: representable) - offWindowHosting.frame = detachedRoot.bounds - offWindowHosting.autoresizingMask = [.width, .height] - detachedRoot.addSubview(offWindowHosting) - detachedRoot.layoutSubtreeIfNeeded() - offWindowHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host") - XCTAssertTrue(visibleHost.window === window) - XCTAssertTrue( - panel.webView.superview === visibleSlot, - "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn" - ) - XCTAssertTrue( - inspectorView.superview === visibleSlot, - "An off-window replacement host should leave DevTools companion views in the visible local host" - ) - } - - func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() { - let (panel, _) = makePanelWithInspector() - XCTAssertTrue(panel.showDeveloperTools()) - - let paneId = PaneID(id: UUID()) - let representable = WebViewRepresentable( - panel: panel, - paneId: paneId, - shouldAttachWebView: false, - useLocalInlineHosting: true, - shouldFocusWebView: false, - isPanelFocused: true, - portalZPriority: 0, - paneDropZone: nil, - searchOverlay: nil, - paneTopChromeHeight: 0 - ) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let narrowHosting = NSHostingView(rootView: representable) - narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240) - contentView.addSubview(narrowHosting) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - narrowHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected initial local inline slot") - return - } - - let inspectorView = WKInspectorProbeView( - frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72) - ) - inspectorView.autoresizingMask = [.width] - initialSlot.addSubview(inspectorView) - panel.webView.frame = NSRect( - x: 0, - y: inspectorView.frame.maxY, - width: initialSlot.bounds.width, - height: initialSlot.bounds.height - inspectorView.frame.height - ) - initialSlot.layoutSubtreeIfNeeded() - - let replacementHosting = NSHostingView(rootView: representable) - replacementHosting.frame = contentView.bounds - replacementHosting.autoresizingMask = [.width, .height] - contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting) - contentView.layoutSubtreeIfNeeded() - replacementHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - replacementHosting.rootView = representable - contentView.layoutSubtreeIfNeeded() - replacementHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - narrowHosting.removeFromSuperview() - contentView.layoutSubtreeIfNeeded() - replacementHosting.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let replacementHost = findHostContainerView(in: replacementHosting), - let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else { - XCTFail("Expected replacement local inline host") - return - } - - XCTAssertTrue( - panel.webView.superview === replacementSlot, - "A visible replacement local host should take over the hosted page" - ) - XCTAssertTrue( - inspectorView.superview === replacementSlot, - "A visible replacement local host should move the DevTools companion views with the page" - ) - XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5) - XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5) - XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) - XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5) - XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5) - XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5) - XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) - XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5) - } -} - -final class WorkspaceShortcutMapperTests: XCTestCase { - func testCommandNineMapsToLastWorkspaceIndex() { - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 4), 3) - XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 12), 11) - } - - func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() { - XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 0, workspaceCount: 12), 1) - XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 7, workspaceCount: 12), 8) - XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 11, workspaceCount: 12), 9) - XCTAssertNil(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 8, workspaceCount: 12)) - } -} - -final class BrowserOmnibarCommandNavigationTests: XCTestCase { - func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() { - XCTAssertNil( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: false, - flags: [], - keyCode: 126 - ) - ) - XCTAssertNil( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: true, - flags: [.command], - keyCode: 126 - ) - ) - XCTAssertEqual( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: true, - flags: [], - keyCode: 126 - ), - -1 - ) - XCTAssertEqual( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: true, - flags: [], - keyCode: 125 - ), - 1 - ) - } - - func testArrowNavigationDeltaIgnoresCapsLockModifier() { - XCTAssertEqual( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: true, - flags: [.capsLock], - keyCode: 126 - ), - -1 - ) - XCTAssertEqual( - browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: true, - flags: [.capsLock], - keyCode: 125 - ), - 1 - ) - } - - func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { - XCTAssertNil( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: false, - flags: [.command], - chars: "n" - ) - ) - - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.command], - chars: "n" - ), - 1 - ) - - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.command], - chars: "p" - ), - -1 - ) - - XCTAssertNil( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.command, .shift], - chars: "n" - ) - ) - - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.control], - chars: "p" - ), - -1 - ) - - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.control], - chars: "n" - ), - 1 - ) - } - - func testCommandNavigationDeltaIgnoresCapsLockModifier() { - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.control, .capsLock], - chars: "n" - ), - 1 - ) - XCTAssertEqual( - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: true, - flags: [.command, .capsLock], - chars: "p" - ), - -1 - ) - } - - func testSubmitOnReturnIgnoresCapsLockModifier() { - XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) - XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) - XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) - XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) - XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) - } -} - -final class BrowserReturnKeyDownRoutingTests: XCTestCase { - func testRoutesForReturnWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testRoutesForKeypadEnterWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 76, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testDoesNotRouteForNonEnterKey() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 13, - firstResponderIsBrowser: true, - flags: [] - ) - ) - } - - func testDoesNotRouteWhenFirstResponderIsNotBrowser() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: false, - flags: [] - ) - ) - } - - func testRoutesForShiftReturnWhenBrowserFirstResponder() { - XCTAssertTrue( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.shift] - ) - ) - } - - func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.command, .shift] - ) - ) - } - - func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.command] - ) - ) - } - - func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.option] - ) - ) - } - - func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() { - XCTAssertFalse( - shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: 36, - firstResponderIsBrowser: true, - flags: [.control] - ) - ) - } -} - -final class FullScreenShortcutTests: XCTestCase { - func testMatchesCommandControlF() { - XCTAssertTrue( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "f", - keyCode: 3 - ) - ) - } - - func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() { - XCTAssertTrue( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "", - keyCode: 3, - layoutCharacterProvider: { _, _ in nil } - ) - ) - } - - func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "", - keyCode: 3, - layoutCharacterProvider: { _, _ in "u" } - ) - ) - } - - func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { - XCTAssertTrue( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "", - keyCode: 3, - layoutCharacterProvider: { _, modifierFlags in - modifierFlags.contains(.command) ? "f" : "u" - } - ) - ) - } - - func testMatchesCommandControlFWhenCharsAreControlSequence() { - XCTAssertTrue( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "\u{06}", - keyCode: 3, - layoutCharacterProvider: { _, _ in nil } - ) - ) - } - - func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "u", - keyCode: 3 - ) - ) - } - - func testIgnoresCapsLockForCommandControlF() { - XCTAssertTrue( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control, .capsLock], - chars: "f", - keyCode: 3 - ) - ) - } - - func testRejectsWhenControlIsMissing() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command], - chars: "f", - keyCode: 3 - ) - ) - } - - func testRejectsAdditionalModifiers() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control, .shift], - chars: "f", - keyCode: 3 - ) - ) - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control, .option], - chars: "f", - keyCode: 3 - ) - ) - } - - func testRejectsWhenCommandIsMissing() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.control], - chars: "f", - keyCode: 3 - ) - ) - } - - func testRejectsNonFKey() { - XCTAssertFalse( - shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: [.command, .control], - chars: "r", - keyCode: 15 - ) - ) - } -} - -final class BrowserZoomShortcutActionTests: XCTestCase { - func testZoomInSupportsEqualsAndPlusVariants() { - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24), - .zoomIn - ) - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24), - .zoomIn - ) - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), - .zoomIn - ) - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), - .zoomIn - ) - } - - func testZoomOutSupportsMinusAndUnderscoreVariants() { - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27), - .zoomOut - ) - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27), - .zoomOut - ) - } - - func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() { - XCTAssertEqual( - browserZoomShortcutAction( - flags: [.command, .shift], - chars: ";", - keyCode: 41, - literalChars: "+" - ), - .zoomIn - ) - - XCTAssertNil( - browserZoomShortcutAction( - flags: [.command, .shift], - chars: ";", - keyCode: 41 - ) - ) - } - - func testZoomRequiresCommandWithoutOptionOrControl() { - XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24)) - XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24)) - XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27)) - } - - func testResetSupportsCommandZero() { - XCTAssertEqual( - browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29), - .reset - ) - } -} - -final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { - func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() { - XCTAssertTrue( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: [.command], - chars: "=", - keyCode: 24 - ) - ) - XCTAssertTrue( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: [.command], - chars: "-", - keyCode: 27 - ) - ) - XCTAssertTrue( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: [.command], - chars: "0", - keyCode: 29 - ) - ) - } - - func testDoesNotRouteWhenFirstResponderIsNotGhostty() { - XCTAssertFalse( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: false, - flags: [.command], - chars: "=", - keyCode: 24 - ) - ) - } - - func testDoesNotRouteForNonZoomShortcuts() { - XCTAssertFalse( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: [.command], - chars: "n", - keyCode: 45 - ) - ) - } - - func testRoutesForShiftedLiteralZoomShortcut() { - XCTAssertTrue( - shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: [.command, .shift], - chars: ";", - keyCode: 41, - literalChars: "+" - ) - ) - } -} - -final class GhosttyResponderResolutionTests: XCTestCase { - private final class FocusProbeView: NSView { - override var acceptsFirstResponder: Bool { true } - } - - func testResolvesGhosttyViewFromDescendantResponder() { - let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) - let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) - ghosttyView.addSubview(descendant) - - XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) - } - - func testResolvesGhosttyViewFromGhosttyResponder() { - let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) - XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) - } - - func testReturnsNilForUnrelatedResponder() { - let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) - XCTAssertNil(cmuxOwningGhosttyView(for: view)) - } -} - -final class CommandPaletteKeyboardNavigationTests: XCTestCase { - func testArrowKeysMoveSelectionWithoutModifiers() { - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [], - chars: "", - keyCode: 125 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [], - chars: "", - keyCode: 126 - ), - -1 - ) - XCTAssertNil( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.shift], - chars: "", - keyCode: 125 - ) - ) - } - - func testControlLetterNavigationSupportsPrintableAndControlChars() { - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "n", - keyCode: 45 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "\u{0e}", - keyCode: 45 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "p", - keyCode: 35 - ), - -1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "\u{10}", - keyCode: 35 - ), - -1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "j", - keyCode: 38 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "\u{0a}", - keyCode: 38 - ), - 1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "k", - keyCode: 40 - ), - -1 - ) - XCTAssertEqual( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "\u{0b}", - keyCode: 40 - ), - -1 - ) - } - - func testIgnoresUnsupportedModifiersAndKeys() { - XCTAssertNil( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.command], - chars: "n", - keyCode: 45 - ) - ) - XCTAssertNil( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control, .shift], - chars: "n", - keyCode: 45 - ) - ) - XCTAssertNil( - commandPaletteSelectionDeltaForKeyboardNavigation( - flags: [.control], - chars: "x", - keyCode: 7 - ) - ) - } -} - -final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { - func testDoesNotConsumeWhenPaletteIsNotVisible() { - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: false, - normalizedFlags: [.command], - chars: "n", - keyCode: 45 - ) - ) - } - - func testConsumesAppCommandShortcutsWhenPaletteIsVisible() { - XCTAssertTrue( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "n", - keyCode: 45 - ) - ) - XCTAssertTrue( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "t", - keyCode: 17 - ) - ) - XCTAssertTrue( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command, .shift], - chars: ",", - keyCode: 43 - ) - ) - } - - func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() { - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "v", - keyCode: 9 - ) - ) - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "z", - keyCode: 6 - ) - ) - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command, .shift], - chars: "z", - keyCode: 6 - ) - ) - } - - func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() { - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "", - keyCode: 123 - ) - ) - XCTAssertFalse( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [.command], - chars: "", - keyCode: 51 - ) - ) - } - - func testConsumesEscapeWhenPaletteIsVisible() { - XCTAssertTrue( - shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: true, - normalizedFlags: [], - chars: "", - keyCode: 53 - ) - ) - } -} - -final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { - func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { - let panelId = UUID() - XCTAssertTrue( - ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( - focusedPanelIsBrowser: true, - focusedBrowserAddressBarPanelId: panelId, - focusedPanelId: panelId - ) - ) - } - - func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() { - let panelId = UUID() - XCTAssertFalse( - ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( - focusedPanelIsBrowser: false, - focusedBrowserAddressBarPanelId: panelId, - focusedPanelId: panelId - ) - ) - } - - func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() { - XCTAssertFalse( - ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( - focusedPanelIsBrowser: true, - focusedBrowserAddressBarPanelId: UUID(), - focusedPanelId: UUID() - ) - ) - } -} - -final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { - private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" - - private func makeDefaults() -> UserDefaults { - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return defaults - } - - func testDefaultsToSelectAllWhenUnset() { - let defaults = makeDefaults() - XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } - - func testReturnsFalseWhenStoredFalse() { - let defaults = makeDefaults() - defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } - - func testReturnsTrueWhenStoredTrue() { - let defaults = makeDefaults() - defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } -} - -final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { - func testFirstEntryPinsToTopAnchor() { - let anchor = ContentView.commandPaletteScrollPositionAnchor( - selectedIndex: 0, - resultCount: 20 - ) - XCTAssertEqual(anchor, UnitPoint.top) - } - - func testLastEntryPinsToBottomAnchor() { - let anchor = ContentView.commandPaletteScrollPositionAnchor( - selectedIndex: 19, - resultCount: 20 - ) - XCTAssertEqual(anchor, UnitPoint.bottom) - } - - func testMiddleEntryUsesNilAnchorForMinimalScroll() { - let anchor = ContentView.commandPaletteScrollPositionAnchor( - selectedIndex: 6, - resultCount: 20 - ) - XCTAssertNil(anchor) - } - - func testEmptyResultsProduceNoAnchor() { - let anchor = ContentView.commandPaletteScrollPositionAnchor( - selectedIndex: 0, - resultCount: 0 - ) - XCTAssertNil(anchor) - } -} - -final class ShortcutHintModifierPolicyTests: XCTestCase { - func testShortcutHintRequiresEnabledCommandOnlyModifier() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) - } - } - - func testCommandHintCanBeDisabledInSettings() { - withDefaultsSuite { defaults in - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) - } - } - - func testCommandHintDefaultsToEnabledWhenSettingMissing() { - withDefaultsSuite { defaults in - defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) - } - } - - func testShortcutHintUsesIntentionalHoldDelay() { - XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) - } - - func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { - XCTAssertTrue( - ShortcutHintModifierPolicy.isCurrentWindow( - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: 42, - keyWindowNumber: 42 - ) - ) - - XCTAssertFalse( - ShortcutHintModifierPolicy.isCurrentWindow( - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: 7, - keyWindowNumber: 42 - ) - ) - - XCTAssertFalse( - ShortcutHintModifierPolicy.isCurrentWindow( - hostWindowNumber: 42, - hostWindowIsKey: false, - eventWindowNumber: 42, - keyWindowNumber: 42 - ) - ) - } - - func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { - withDefaultsSuite { defaults in - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - XCTAssertTrue( - ShortcutHintModifierPolicy.shouldShowHints( - for: [.command], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 42, - defaults: defaults - ) - ) - - XCTAssertFalse( - ShortcutHintModifierPolicy.shouldShowHints( - for: [.command], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 7, - defaults: defaults - ) - ) - - XCTAssertTrue( - ShortcutHintModifierPolicy.shouldShowHints( - for: [.command], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 42, - defaults: defaults - ) - ) - - XCTAssertFalse( - ShortcutHintModifierPolicy.shouldShowHints( - for: [.control], - hostWindowNumber: 42, - hostWindowIsKey: true, - eventWindowNumber: nil, - keyWindowNumber: 42, - defaults: defaults - ) - ) - } - } - - private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { - let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create defaults suite") - return - } - - defaults.removePersistentDomain(forName: suiteName) - body(defaults) - defaults.removePersistentDomain(forName: suiteName) - } -} - -final class ShortcutHintDebugSettingsTests: XCTestCase { - func testClampKeepsValuesWithinSupportedRange() { - XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0) - XCTAssertEqual(ShortcutHintDebugSettings.clamped(4.0), 4.0) - XCTAssertEqual(ShortcutHintDebugSettings.clamped(-100.0), ShortcutHintDebugSettings.offsetRange.lowerBound) - XCTAssertEqual(ShortcutHintDebugSettings.clamped(100.0), ShortcutHintDebugSettings.offsetRange.upperBound) - } - - func testDefaultOffsetsMatchCurrentBadgePlacements() { - XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintX, 0.0) - XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintY, 0.0) - XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintX, 4.0) - XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintY, 0.0) - XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0) - XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0) - XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints) - XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold) - } - - func testShowHintsOnCommandHoldSettingRespectsStoredValue() { - let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create defaults suite") - return - } - - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) - - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) - - defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) - } - - func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { - let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create defaults suite") - return - } - - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) - defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - - ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) - - XCTAssertEqual( - defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool, - ShortcutHintDebugSettings.defaultAlwaysShowHints - ) - XCTAssertEqual( - defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, - ShortcutHintDebugSettings.defaultShowHintsOnCommandHold - ) - } -} - -final class DevBuildBannerDebugSettingsTests: XCTestCase { - func testShowSidebarBannerDefaultsToVisible() { - let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) - } - - func testShowSidebarBannerRespectsStoredValue() { - let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) - - defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) - } -} - -final class ShortcutHintLanePlannerTests: XCTestCase { - func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { - let intervals: [ClosedRange] = [0...20, 28...40, 48...64] - XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0]) - } - - func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() { - let intervals: [ClosedRange] = [0...20, 18...34, 22...38, 40...56] - XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 1, 2, 0]) - } -} - -final class ShortcutHintHorizontalPlannerTests: XCTestCase { - func testAssignRightEdgesResolvesOverlapWithMinimumSpacing() { - let intervals: [ClosedRange] = [0...20, 18...34, 30...46] - let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 6) - - XCTAssertEqual(rightEdges.count, intervals.count) - - let adjustedIntervals = zip(intervals, rightEdges).map { interval, rightEdge in - let width = interval.upperBound - interval.lowerBound - return (rightEdge - width)...rightEdge - } - - XCTAssertGreaterThanOrEqual(adjustedIntervals[1].lowerBound - adjustedIntervals[0].upperBound, 6) - XCTAssertGreaterThanOrEqual(adjustedIntervals[2].lowerBound - adjustedIntervals[1].upperBound, 6) - } - - func testAssignRightEdgesKeepsAlreadySeparatedIntervalsInPlace() { - let intervals: [ClosedRange] = [0...12, 20...32, 40...52] - let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 4) - XCTAssertEqual(rightEdges, [12, 32, 52]) - } -} - -final class WorkspacePlacementSettingsTests: XCTestCase { - func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() { - let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent) - } - - func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() { - let suiteName = "WorkspacePlacementSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(NewWorkspacePlacement.top.rawValue, forKey: WorkspacePlacementSettings.placementKey) - XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .top) - - defaults.set("nope", forKey: WorkspacePlacementSettings.placementKey) - XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent) - } - - func testInsertionIndexTopInsertsBeforeUnpinned() { - let index = WorkspacePlacementSettings.insertionIndex( - placement: .top, - selectedIndex: 4, - selectedIsPinned: false, - pinnedCount: 2, - totalCount: 7 - ) - XCTAssertEqual(index, 2) - } - - func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() { - let afterUnpinned = WorkspacePlacementSettings.insertionIndex( - placement: .afterCurrent, - selectedIndex: 3, - selectedIsPinned: false, - pinnedCount: 2, - totalCount: 6 - ) - XCTAssertEqual(afterUnpinned, 4) - - let afterPinned = WorkspacePlacementSettings.insertionIndex( - placement: .afterCurrent, - selectedIndex: 0, - selectedIsPinned: true, - pinnedCount: 2, - totalCount: 6 - ) - XCTAssertEqual(afterPinned, 2) - } - - func testInsertionIndexEndAndNoSelectionAppend() { - let endIndex = WorkspacePlacementSettings.insertionIndex( - placement: .end, - selectedIndex: 1, - selectedIsPinned: false, - pinnedCount: 1, - totalCount: 5 - ) - XCTAssertEqual(endIndex, 5) - - let noSelectionIndex = WorkspacePlacementSettings.insertionIndex( - placement: .afterCurrent, - selectedIndex: nil, - selectedIsPinned: false, - pinnedCount: 0, - totalCount: 5 - ) - XCTAssertEqual(noSelectionIndex, 5) - } -} - -@MainActor -final class WorkspaceCreationPlacementTests: XCTestCase { - func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() { - let currentPlacement = WorkspacePlacementSettings.current() - - let defaultManager = makeManagerWithThreeWorkspaces() - let defaultBaselineOrder = defaultManager.tabs.map(\.id) - let defaultInserted = defaultManager.addWorkspace() - guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else { - XCTFail("Expected inserted workspace in tab list") - return - } - XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder) - - let explicitManager = makeManagerWithThreeWorkspaces() - let explicitBaselineOrder = explicitManager.tabs.map(\.id) - let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement) - guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else { - XCTFail("Expected inserted workspace in tab list") - return - } - XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder) - XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex) - } - - func testAddWorkspaceEndOverrideAlwaysAppends() { - let manager = makeManagerWithThreeWorkspaces() - let baselineCount = manager.tabs.count - guard baselineCount >= 3 else { - XCTFail("Expected at least three workspaces for placement regression test") - return - } - - let inserted = manager.addWorkspace(placementOverride: .end) - guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else { - XCTFail("Expected inserted workspace in tab list") - return - } - - XCTAssertEqual(insertedIndex, baselineCount) - } - - private func makeManagerWithThreeWorkspaces() -> TabManager { - let manager = TabManager() - _ = manager.addWorkspace() - _ = manager.addWorkspace() - if let first = manager.tabs.first { - manager.selectWorkspace(first) - } - return manager - } -} - -final class WorkspaceTabColorSettingsTests: XCTestCase { - func testNormalizedHexAcceptsAndNormalizesValidInput() { - XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123") - XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF") - XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234")) - XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) - } - - func testBuiltInPaletteMatchesOriginalPRPalette() { - let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) - XCTAssertEqual(palette.count, 16) - XCTAssertEqual(palette.first?.name, "Red") - XCTAssertEqual(palette.first?.hex, "#C0392B") - XCTAssertEqual(palette.last?.name, "Charcoal") - XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) - } - - func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { - let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let first = WorkspaceTabColorSettings.defaultPalette[0] - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - first.hex - ) - - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - "#00AA33" - ) - - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - first.hex - ) - } - - func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { - let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertEqual( - WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults), - "#00AA33" - ) - XCTAssertEqual( - WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults), - "#112233" - ) - XCTAssertEqual( - WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults), - "#00AA33" - ) - XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) - - XCTAssertEqual( - WorkspaceTabColorSettings.customColors(defaults: defaults), - ["#00AA33", "#112233"] - ) - } - - func testPaletteIncludesCustomEntriesAndResetClearsAll() { - let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let first = WorkspaceTabColorSettings.defaultPalette[0] - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) - _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) - - let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) - XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) - XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") - XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") - XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") - - WorkspaceTabColorSettings.reset(defaults: defaults) - - XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - first.hex - ) - } - - func testDisplayColorLightModeKeepsOriginalHex() { - let originalHex = "#1A5276" - let rendered = WorkspaceTabColorSettings.displayNSColor( - hex: originalHex, - colorScheme: .light - ) - - XCTAssertEqual(rendered?.hexString(), originalHex) - } - - func testDisplayColorDarkModeBrightensColor() { - let originalHex = "#1A5276" - guard let base = NSColor(hex: originalHex), - let rendered = WorkspaceTabColorSettings.displayNSColor( - hex: originalHex, - colorScheme: .dark - ) else { - XCTFail("Expected valid color conversion") - return - } - - XCTAssertNotEqual(rendered.hexString(), originalHex) - XCTAssertGreaterThan(rendered.luminance, base.luminance) - } - - func testDisplayColorDarkModeKeepsGrayscaleNeutral() { - let originalHex = "#808080" - guard let base = NSColor(hex: originalHex), - let rendered = WorkspaceTabColorSettings.displayNSColor( - hex: originalHex, - colorScheme: .dark - ), - let renderedSRGB = rendered.usingColorSpace(.sRGB) else { - XCTFail("Expected valid color conversion") - return - } - - XCTAssertGreaterThan(rendered.luminance, base.luminance) - XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003) - XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003) - } - - func testDisplayColorForceBrightensInLightMode() { - let originalHex = "#1A5276" - guard let base = NSColor(hex: originalHex), - let rendered = WorkspaceTabColorSettings.displayNSColor( - hex: originalHex, - colorScheme: .light, - forceBright: true - ) else { - XCTFail("Expected valid color conversion") - return - } - - XCTAssertNotEqual(rendered.hexString(), originalHex) - XCTAssertGreaterThan(rendered.luminance, base.luminance) - } -} - -final class WorkspaceAutoReorderSettingsTests: XCTestCase { - func testDefaultIsEnabled() { - let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) - } - - func testDisabledWhenSetToFalse() { - let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(false, forKey: WorkspaceAutoReorderSettings.key) - XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) - } - - func testEnabledWhenSetToTrue() { - let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) - XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) - } -} - -final class SidebarBranchLayoutSettingsTests: XCTestCase { - func testDefaultUsesVerticalLayout() { - let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) - } - - func testStoredPreferenceOverridesDefault() { - let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(false, forKey: SidebarBranchLayoutSettings.key) - XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) - - defaults.set(true, forKey: SidebarBranchLayoutSettings.key) - XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) - } -} - -final class SidebarWorkspaceDetailSettingsTests: XCTestCase { - func testDefaultPreferencesWhenUnset() { - let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) - XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) - XCTAssertTrue( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), - hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) - ) - ) - } - - func testStoredPreferencesOverrideDefaults() { - let suiteName = "SidebarWorkspaceDetailSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey) - defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey) - - XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) - XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) - XCTAssertFalse( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), - hideAllDetails: false - ) - ) - XCTAssertFalse( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: true, - hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) - ) - ) - } -} - -final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase { - func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() { - XCTAssertEqual( - SidebarWorkspaceAuxiliaryDetailVisibility.resolved( - showMetadata: true, - showLog: false, - showProgress: true, - showBranchDirectory: false, - showPullRequests: true, - showPorts: false, - hideAllDetails: false - ), - SidebarWorkspaceAuxiliaryDetailVisibility( - showsMetadata: true, - showsLog: false, - showsProgress: true, - showsBranchDirectory: false, - showsPullRequests: true, - showsPorts: false - ) - ) - } - - func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() { - XCTAssertEqual( - SidebarWorkspaceAuxiliaryDetailVisibility.resolved( - showMetadata: true, - showLog: true, - showProgress: true, - showBranchDirectory: true, - showPullRequests: true, - showPorts: true, - hideAllDetails: true - ), - .hidden - ) - } -} - -final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { - func testDefaultStyleWhenUnset() { - let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey) - XCTAssertEqual( - SidebarActiveTabIndicatorSettings.current(defaults: defaults), - SidebarActiveTabIndicatorSettings.defaultStyle - ) - } - - func testStoredStyleParsesAndInvalidFallsBack() { - let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey) - XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) - - defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey) - XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) - - defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey) - XCTAssertEqual( - SidebarActiveTabIndicatorSettings.current(defaults: defaults), - SidebarActiveTabIndicatorSettings.defaultStyle - ) - } -} - -final class AppearanceSettingsTests: XCTestCase { - func testResolvedModeDefaultsToSystemWhenUnset() { - let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey) - - let resolved = AppearanceSettings.resolvedMode(defaults: defaults) - XCTAssertEqual(resolved, .system) - XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) - } -} - -final class QuitWarningSettingsTests: XCTestCase { - func testDefaultWarnBeforeQuitIsEnabledWhenUnset() { - let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey) - - XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) - } - - func testStoredPreferenceOverridesDefault() { - let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey) - XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults)) - - defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey) - XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) - } -} - -final class UpdateChannelSettingsTests: XCTestCase { - func testResolvedFeedFallsBackWhenInfoFeedMissing() { - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) - XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) - XCTAssertFalse(resolved.isNightly) - XCTAssertTrue(resolved.usedFallback) - } - - func testResolvedFeedFallsBackWhenInfoFeedEmpty() { - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "") - XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) - XCTAssertFalse(resolved.isNightly) - XCTAssertTrue(resolved.usedFallback) - } - - func testResolvedFeedUsesInfoFeedForStableChannel() { - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) - XCTAssertEqual(resolved.url, infoFeed) - XCTAssertFalse(resolved.isNightly) - XCTAssertFalse(resolved.usedFallback) - } - - func testResolvedFeedDetectsNightlyFromInfoFeedURL() { - let resolved = UpdateFeedResolver.resolvedFeedURLString( - infoFeedURL: "https://example.com/nightly/appcast.xml" - ) - XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml") - XCTAssertTrue(resolved.isNightly) - XCTAssertFalse(resolved.usedFallback) - } -} - -final class UpdateSettingsTests: XCTestCase { - func testApplyEnablesAutomaticChecksAndDailySchedule() { - let defaults = makeDefaults() - UpdateSettings.apply(to: defaults) - - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) - } - - func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { - let defaults = makeDefaults() - defaults.set(false, forKey: UpdateSettings.automaticChecksKey) - defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) - defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) - - UpdateSettings.apply(to: defaults) - - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) - - defaults.set(false, forKey: UpdateSettings.automaticChecksKey) - UpdateSettings.apply(to: defaults) - - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - } - - private func makeDefaults() -> UserDefaults { - let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Failed to create isolated UserDefaults suite") - } - defaults.removePersistentDomain(forName: suiteName) - return defaults - } -} - -final class SidebarRemoteErrorCopySupportTests: XCTestCase { - func testMenuLabelIsNilWhenThereAreNoErrors() { - XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) - XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) - } - - func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to start reverse relay" - ) - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - "SSH error (devbox:22): failed to start reverse relay" - ) - } - - func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox-a:22", - detail: "connection timed out" - ), - SidebarRemoteErrorCopyEntry( - workspaceTitle: "beta", - target: "devbox-b:22", - detail: "permission denied" - ), - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - """ - 1. alpha (devbox-a:22): connection timed out - 2. beta (devbox-b:22): permission denied - """ - ) - } - - func testClipboardTextSingleEntryUsesStructuredEntryFields() { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to bootstrap daemon" - ) - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), - "SSH error (devbox:22): failed to bootstrap daemon" - ) - } -} - -final class WorkspaceReorderTests: XCTestCase { - @MainActor - func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { - let manager = TabManager() - let first = manager.tabs[0] - let second = manager.addWorkspace() - let third = manager.addWorkspace() - - manager.selectWorkspace(second) - XCTAssertEqual(manager.selectedTabId, second.id) - - XCTAssertTrue(manager.reorderWorkspace(tabId: second.id, toIndex: 0)) - XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id, third.id]) - XCTAssertEqual(manager.selectedTabId, second.id) - } - - @MainActor - func testReorderWorkspaceClampsOutOfRangeTargetIndex() { - let manager = TabManager() - let first = manager.tabs[0] - let second = manager.addWorkspace() - let third = manager.addWorkspace() - - XCTAssertTrue(manager.reorderWorkspace(tabId: first.id, toIndex: 999)) - XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id]) - } - - @MainActor - func testReorderWorkspaceReturnsFalseForUnknownWorkspace() { - let manager = TabManager() - XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0)) - } - - @MainActor - func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { - let manager = TabManager() - let firstPinned = manager.tabs[0] - manager.setPinned(firstPinned, pinned: true) - let secondPinned = manager.addWorkspace() - manager.setPinned(secondPinned, pinned: true) - let unpinned = manager.addWorkspace() - - XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) - XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) - } - - @MainActor - func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { - let manager = TabManager() - let firstPinned = manager.tabs[0] - manager.setPinned(firstPinned, pinned: true) - let secondPinned = manager.addWorkspace() - manager.setPinned(secondPinned, pinned: true) - let unpinned = manager.addWorkspace() - - XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) - XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) - } -} - -@MainActor -final class WorkspaceNotificationReorderTests: XCTestCase { - func testNotificationAutoReorderDoesNotMovePinnedWorkspace() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let notificationStore = TerminalNotificationStore.shared - - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - let defaults = UserDefaults.standard - let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key) - let originalAppFocusOverride = AppFocusState.overrideIsFocused - - notificationStore.replaceNotificationsForTesting([]) - notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = notificationStore - defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) - AppFocusState.overrideIsFocused = false - - defer { - notificationStore.replaceNotificationsForTesting([]) - notificationStore.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - AppFocusState.overrideIsFocused = originalAppFocusOverride - if let originalAutoReorderSetting { - defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key) - } else { - defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key) - } - } - - let firstPinned = manager.tabs[0] - manager.setPinned(firstPinned, pinned: true) - let secondPinned = manager.addWorkspace() - manager.setPinned(secondPinned, pinned: true) - let unpinned = manager.addWorkspace() - let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id] - - notificationStore.addNotification( - tabId: secondPinned.id, - surfaceId: nil, - title: "Build finished", - subtitle: "", - body: "Pinned workspaces should stay put" - ) - - XCTAssertEqual(manager.tabs.map(\.id), expectedOrder) - } -} - -@MainActor -final class TabManagerChildExitCloseTests: XCTestCase { - func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { - let manager = TabManager() - let first = manager.tabs[0] - let second = manager.addWorkspace() - let third = manager.addWorkspace() - - manager.selectWorkspace(second) - XCTAssertEqual(manager.selectedTabId, second.id) - - guard let secondPanelId = second.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) - - XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id]) - XCTAssertEqual( - manager.selectedTabId, - third.id, - "Expected selection to stay at the same index after deleting the selected workspace" - ) - } - - func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() { - let manager = TabManager() - let first = manager.tabs[0] - let second = manager.addWorkspace() - - manager.selectWorkspace(second) - XCTAssertEqual(manager.selectedTabId, second.id) - - guard let secondPanelId = second.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) - - XCTAssertEqual(manager.tabs.map(\.id), [first.id]) - XCTAssertEqual( - manager.selectedTabId, - first.id, - "Expected previous workspace to be selected after closing the last-index workspace" - ) - } - - func testChildExitOnNonLastPanelClosesOnlyPanel() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { - XCTFail("Expected split terminal panel to be created") - return - } - - let panelCountBefore = workspace.panels.count - manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.tabs.first?.id, workspace.id) - XCTAssertEqual(workspace.panels.count, panelCountBefore - 1) - XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain") - } -} - -@MainActor -final class WorkspaceTeardownTests: XCTestCase { - func testTeardownAllPanelsClearsPanelMetadataCaches() { - let workspace = Workspace() - guard let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected focused panel in new workspace") - return - } - - workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title") - workspace.setPanelPinned(panelId: initialPanelId, pinned: true) - - guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { - XCTFail("Expected split panel to be created") - return - } - - workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title") - workspace.setPanelPinned(panelId: splitPanel.id, pinned: true) - workspace.markPanelUnread(initialPanelId) - - XCTAssertFalse(workspace.panels.isEmpty) - XCTAssertFalse(workspace.panelTitles.isEmpty) - XCTAssertFalse(workspace.panelCustomTitles.isEmpty) - XCTAssertFalse(workspace.pinnedPanelIds.isEmpty) - XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty) - - workspace.teardownAllPanels() - - XCTAssertTrue(workspace.panels.isEmpty) - XCTAssertTrue(workspace.panelTitles.isEmpty) - XCTAssertTrue(workspace.panelCustomTitles.isEmpty) - XCTAssertTrue(workspace.pinnedPanelIds.isEmpty) - XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty) - } -} - -@MainActor -final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { - func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { - let workspace = Workspace() - guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { - XCTFail("Expected focused pane in new workspace") - return - } - - let staleCurrentDirectory = workspace.currentDirectory - let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" - guard let sourcePanel = workspace.newTerminalSurface( - inPane: sourcePaneId, - focus: false, - workingDirectory: requestedDirectory - ) else { - XCTFail("Expected source terminal panel to be created") - return - } - - XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) - XCTAssertNil( - workspace.panelDirectories[sourcePanel.id], - "Expected requested cwd to exist before shell integration reports a live cwd" - ) - XCTAssertEqual( - workspace.currentDirectory, - staleCurrentDirectory, - "Expected focused workspace cwd to remain stale before panel directory updates" - ) - - guard let splitPanel = workspace.newTerminalSplit( - from: sourcePanel.id, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected split terminal panel to be created") - return - } - - XCTAssertEqual( - splitPanel.requestedWorkingDirectory, - requestedDirectory, - "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" - ) - } -} - -@MainActor -final class TabManagerWorkspaceOwnershipTests: XCTestCase { - func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() { - let manager = TabManager() - _ = manager.addWorkspace() - let initialTabIds = manager.tabs.map(\.id) - let initialSelectedTabId = manager.selectedTabId - - let externalWorkspace = Workspace(title: "External workspace") - let externalPanelCountBefore = externalWorkspace.panels.count - let externalPanelTitlesBefore = externalWorkspace.panelTitles - - manager.closeWorkspace(externalWorkspace) - - XCTAssertEqual(manager.tabs.map(\.id), initialTabIds) - XCTAssertEqual(manager.selectedTabId, initialSelectedTabId) - XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore) - XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore) - } -} - -@MainActor -final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { - func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() { - let manager = TabManager() - let second = manager.addWorkspace() - let third = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - manager.setCustomTitle(tabId: third.id, title: "Gamma") - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return true - } - - manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspaces.message", - defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close") - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, false) - XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"]) - } - - func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() { - let manager = TabManager() - let second = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return false - } - - manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspacesWindow.message", - defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1) - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, true) - XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) - } - - func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() { - let manager = TabManager() - let second = manager.addWorkspace() - let third = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - manager.setCustomTitle(tabId: third.id, title: "Gamma") - manager.selectWorkspace(second) - manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id]) - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return false - } - - manager.closeCurrentWorkspaceWithConfirmation() - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspaces.message", - defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog") - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, false) - XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"]) - } -} - -@MainActor -final class TabManagerCloseCurrentPanelTests: XCTestCase { - func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected selected workspace and focused terminal panel") - return - } - - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) - workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) - - var promptCount = 0 - manager.confirmCloseHandler = { _, _, _ in - promptCount += 1 - return false - } - - manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state") - XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close") - XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel") - } - - func testRuntimeClosePromptsWhenShellReportsRunningCommand() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected selected workspace and focused terminal panel") - return - } - - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) - workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) - - var promptCount = 0 - manager.confirmCloseHandler = { _, _, _ in - promptCount += 1 - return false - } - - manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) - - XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation") - XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open") - } - - func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) - XCTAssertEqual(secondWorkspace.panels.count, 1) - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - - func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) - XCTAssertEqual(secondWorkspace.panels.count, 1) - - guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") - return - } - - secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) - XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - - func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - let initialWorkspaceId = workspace.id - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(workspace.panels.count, 1) - - XCTAssertTrue(workspace.closePanel(initialPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) - XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) - XCTAssertNil(workspace.panels[initialPanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) - } - - func testCloseCurrentPanelIgnoresStaleSurfaceId() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - - manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID()) - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id]) - } - - func testCloseCurrentPanelClearsNotificationsForClosedSurface() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - store.replaceNotificationsForTesting([]) - store.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - store.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - store.addNotification( - tabId: workspace.id, - surfaceId: initialPanelId, - title: "Unread", - subtitle: "", - body: "" - ) - XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) - } -} - -@MainActor -final class TabManagerNotificationFocusTests: XCTestCase { - func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected split setup to succeed") - return - } - - workspace.focusPanel(leftPanelId) - XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") - XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") - - XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) - drainMainQueue() - drainMainQueue() - - XCTAssertFalse( - workspace.bonsplitController.isSplitZoomed, - "Expected notification focus to exit split zoom so the target pane becomes visible" - ) - XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") - } - - func testFocusTabFromNotificationReturnsFalseForMissingPanel() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace else { - XCTFail("Expected selected workspace") - return - } - - XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) - } -} - -@MainActor -final class TabManagerPendingUnfocusPolicyTests: XCTestCase { - func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { - let tabId = UUID() - - XCTAssertFalse( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: tabId, - selectedTabId: tabId - ) - ) - } - - func testUnfocusesWhenPendingTabIsNotSelected() { - XCTAssertTrue( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: UUID(), - selectedTabId: UUID() - ) - ) - XCTAssertTrue( - TabManager.shouldUnfocusPendingWorkspace( - pendingTabId: UUID(), - selectedTabId: nil - ) - ) - } -} - -@MainActor -final class TabManagerSurfaceCreationTests: XCTestCase { - func testNewSurfaceFocusesCreatedSurface() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace else { - XCTFail("Expected a selected workspace") - return - } - - let beforePanels = Set(workspace.panels.keys) - manager.newSurface() - let afterPanels = Set(workspace.panels.keys) - - let createdPanels = afterPanels.subtracting(beforePanels) - XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path") - guard let createdPanelId = createdPanels.first else { return } - - XCTAssertEqual( - workspace.focusedPanelId, - createdPanelId, - "Expected newly created surface to be focused" - ) - } - - func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let paneId = workspace.bonsplitController.focusedPaneId else { - XCTFail("Expected focused workspace and pane") - return - } - - // Add one extra surface so we verify append-to-end rather than first insert behavior. - _ = workspace.newTerminalSurface(inPane: paneId, focus: false) - - guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else { - XCTFail("Expected browser panel to be created") - return - } - - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let lastSurfaceId = tabs.last?.id else { - XCTFail("Expected at least one surface in pane") - return - } - - XCTAssertEqual( - workspace.panelIdFromSurfaceId(lastSurfaceId), - browserPanelId, - "Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end" - ) - XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") - } - - func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { - let manager = TabManager() - guard let initialWorkspace = manager.selectedWorkspace else { - XCTFail("Expected initial selected workspace") - return - } - guard let url = URL(string: "https://example.com/pull/123") else { - XCTFail("Expected test URL to be valid") - return - } - - let targetWorkspace = manager.addWorkspace(select: false) - manager.selectWorkspace(initialWorkspace) - let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count - let initialPanelCount = targetWorkspace.panels.count - - guard let browserPanelId = manager.openBrowser( - inWorkspace: targetWorkspace.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) else { - XCTFail("Expected browser panel to be created in target workspace") - return - } - - XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") - XCTAssertEqual( - targetWorkspace.bonsplitController.allPaneIds.count, - initialPaneCount + 1, - "Expected split-right browser open to create a new pane" - ) - XCTAssertEqual( - targetWorkspace.panels.count, - initialPanelCount + 1, - "Expected browser panel count to increase by one" - ) - XCTAssertEqual( - targetWorkspace.focusedPanelId, - browserPanelId, - "Expected created browser panel to be focused in target workspace" - ) - XCTAssertTrue( - targetWorkspace.panels[browserPanelId] is BrowserPanel, - "Expected created panel to be a browser panel" - ) - } - - func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, - let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), - let url = URL(string: "https://example.com/pull/456") else { - XCTFail("Expected split setup to succeed") - return - } - - let initialPaneCount = workspace.bonsplitController.allPaneIds.count - - guard let browserPanelId = manager.openBrowser( - inWorkspace: workspace.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) else { - XCTFail("Expected browser panel to be created") - return - } - - XCTAssertEqual( - workspace.bonsplitController.allPaneIds.count, - initialPaneCount, - "Expected split-right browser open to reuse existing panes" - ) - XCTAssertEqual( - workspace.paneId(forPanelId: browserPanelId), - topRightPaneId, - "Expected browser to open in the top-right pane when multiple splits already exist" - ) - - let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) - guard let lastSurfaceId = targetPaneTabs.last?.id else { - XCTFail("Expected top-right pane to contain tabs") - return - } - XCTAssertEqual( - workspace.panelIdFromSurfaceId(lastSurfaceId), - browserPanelId, - "Expected browser surface to be appended at end in the reused top-right pane" - ) - } -} - -@MainActor -final class TabManagerEqualizeSplitsTests: XCTestCase { - func testEqualizeSplitsSetsEverySplitDividerToHalf() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else { - XCTFail("Expected nested split setup to succeed") - return - } - - let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) - XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout") - - for (index, split) in initialSplits.enumerated() { - guard let splitId = UUID(uuidString: split.id) else { - XCTFail("Expected split ID to be a UUID") - return - } - let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8 - XCTAssertTrue( - workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId), - "Expected to seed divider position for split \(splitId)" - ) - } - - XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed") - - let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) - XCTAssertEqual(equalizedSplits.count, initialSplits.count) - for split in equalizedSplits { - XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) - } - } - - private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { - switch node { - case .pane: - return [] - case .split(let split): - return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) - } - } -} - -@MainActor -final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { - private func makeWindow() -> NSWindow { - NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - } - - private func makeMouseEvent( - type: NSEvent.EventType, - location: NSPoint, - window: NSWindow - ) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create \(type) mouse event") - } - return event - } - - private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { - var stack: [NSView] = [hostedView] - while let current = stack.popLast() { - if let surfaceView = current as? GhosttyNSView { - return surfaceView - } - stack.append(contentsOf: current.subviews) - } - return nil - } - - func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { - let workspace = Workspace() - guard let leftPanelId = workspace.focusedPanelId, - let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected split terminal panels") - return - } - - XCTAssertEqual( - workspace.focusedPanelId, - rightPanel.id, - "Expected the new split panel to be selected before simulating stale focus state" - ) - - // Simulate the split-pane failure mode: Bonsplit already points at the right panel, - // but the active terminal state is still stale on the left panel. - leftPanel.surface.setFocus(true) - leftPanel.hostedView.setActive(true) - rightPanel.surface.setFocus(false) - rightPanel.hostedView.setActive(false) - - workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) - - XCTAssertFalse( - leftPanel.hostedView.debugRenderStats().isActive, - "Expected stale left-pane active state to be cleared" - ) - XCTAssertTrue( - rightPanel.hostedView.debugRenderStats().isActive, - "Expected terminal-first-responder recovery to reactivate the selected split pane" - ) - } - - func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() { - let workspace = Workspace() - guard let leftPanelId = workspace.focusedPanelId, - let leftPanel = workspace.terminalPanel(for: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected split terminal panels") - return - } - - let window = makeWindow() - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) - rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) - contentView.addSubview(leftPanel.hostedView) - contentView.addSubview(rightPanel.hostedView) - - leftPanel.hostedView.setVisibleInUI(true) - rightPanel.hostedView.setVisibleInUI(true) - leftPanel.hostedView.setFocusHandler { - workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) - } - rightPanel.hostedView.setFocusHandler { - workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) - } - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertEqual( - workspace.focusedPanelId, - rightPanel.id, - "Expected the clicked split pane to already be selected before simulating stale focus state" - ) - - // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale - // active state remains on the left and the right pane's AppKit focus callback never fires - // after split reparent/layout churn. - leftPanel.surface.setFocus(true) - leftPanel.hostedView.setActive(true) - rightPanel.surface.setFocus(false) - rightPanel.hostedView.setActive(false) - rightPanel.hostedView.suppressReparentFocus() - - guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { - XCTFail("Expected right terminal surface view") - return - } - - let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil) - let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) - rightSurfaceView.mouseDown(with: event) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertFalse( - leftPanel.hostedView.debugRenderStats().isActive, - "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed" - ) - XCTAssertTrue( - rightPanel.hostedView.debugRenderStats().isActive, - "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed" - ) - XCTAssertTrue( - rightPanel.hostedView.isSurfaceViewFirstResponder(), - "Expected the clicked split pane to become first responder" - ) - } -} - -@MainActor -final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { - func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), - let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { - XCTFail("Expected workspace split setup to succeed") - return - } - - // Programmatic split focuses the new right panel by default. - XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) - - let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) - XCTAssertEqual( - sourcePanel?.id, - leftPanelId, - "Expected inheritance to use the selected terminal in the target pane" - ) - } - - func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalPanelId = workspace.focusedPanelId, - let paneId = workspace.paneId(forPanelId: terminalPanelId), - let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { - XCTFail("Expected workspace browser setup to succeed") - return - } - - XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) - - let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) - XCTAssertEqual( - sourcePanel?.id, - terminalPanelId, - "Expected inheritance to fall back to a terminal in the pane when browser is selected" - ) - } - - func testPreferredTerminalPanelWinsWhenProvided() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with a terminal panel") - return - } - - let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) - XCTAssertEqual(sourcePanel?.id, terminalPanelId) - } - - func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftTerminalPanelId = workspace.focusedPanelId, - let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), - let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { - XCTFail("Expected split setup to succeed") - return - } - - workspace.focusPanel(leftTerminalPanelId) - _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) - XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) - - let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) - XCTAssertEqual( - sourcePanel?.id, - leftTerminalPanelId, - "Expected inheritance to prefer last focused terminal when browser is focused in another pane" - ) - } -} - -@MainActor -final class WorkspaceBrowserProfileSelectionTests: XCTestCase { - private final class RejectingCreateTabDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { - false - } - } - - private final class RejectingSplitPaneDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { - false - } - } - - func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { - let workspace = Workspace() - let profileA = try makeTemporaryBrowserProfile(named: "Alpha") - let profileB = try makeTemporaryBrowserProfile(named: "Beta") - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let browserA = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: true, - preferredProfileID: profileA.id - ) - ) - _ = try XCTUnwrap( - workspace.newBrowserSplit( - from: browserA.id, - orientation: .horizontal, - preferredProfileID: profileB.id, - focus: true - ) - ) - - XCTAssertEqual( - workspace.preferredBrowserProfileID, - profileB.id, - "Expected workspace preference to drift to the most recently created browser profile" - ) - - let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) - workspace.bonsplitController.focusPane(paneId) - workspace.bonsplitController.selectTab(leftSurfaceId) - - let created = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: false - ) - ) - - XCTAssertEqual( - created.profileID, - profileA.id, - "Expected new browser creation to inherit the selected browser profile from the target pane" - ) - } - - func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { - let workspace = Workspace() - let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") - let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - _ = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: false, - preferredProfileID: preferredProfile.id - ) - ) - XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) - - let rejectingDelegate = RejectingCreateTabDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSurface( - inPane: paneId, - focus: false, - preferredProfileID: unexpectedProfile.id - ) - - XCTAssertNil(created) - XCTAssertEqual( - workspace.preferredBrowserProfileID, - preferredProfile.id, - "Expected a failed browser creation to leave the workspace preferred profile unchanged" - ) - } - - func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { - let workspace = Workspace() - let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") - let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let browser = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: true, - preferredProfileID: preferredProfile.id - ) - ) - XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) - - let rejectingDelegate = RejectingSplitPaneDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSplit( - from: browser.id, - orientation: .horizontal, - preferredProfileID: unexpectedProfile.id, - focus: false - ) - - XCTAssertNil(created) - XCTAssertEqual( - workspace.preferredBrowserProfileID, - preferredProfile.id, - "Expected a failed browser split to leave the workspace preferred profile unchanged" - ) - } -} - -@MainActor -final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { - func testUsesFocusedTerminalWhenTerminalIsFocused() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused terminal") - return - } - - let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() - XCTAssertEqual(sourcePanel?.id, terminalPanelId) - } - - func testFallsBackToTerminalWhenBrowserIsFocused() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalPanelId = workspace.focusedPanelId, - let paneId = workspace.paneId(forPanelId: terminalPanelId), - let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { - XCTFail("Expected selected workspace setup to succeed") - return - } - - XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) - - let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() - XCTAssertEqual( - sourcePanel?.id, - terminalPanelId, - "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" - ) - } - - func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftTerminalPanelId = workspace.focusedPanelId, - let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), - let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { - XCTFail("Expected split setup to succeed") - return - } - - workspace.focusPanel(leftTerminalPanelId) - _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) - XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) - - let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() - XCTAssertEqual( - sourcePanel?.id, - leftTerminalPanelId, - "Expected workspace inheritance source to use last focused terminal across panes" - ) - } -} - -@MainActor -final class BrowserPanelProfileIsolationTests: XCTestCase { - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") - let defaultStore = BrowserHistoryStore.shared - let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) - defaultStore.clearHistory() - alternateStore.clearHistory() - defer { - defaultStore.clearHistory() - alternateStore.clearHistory() - } - - let panel = BrowserPanel( - workspaceId: UUID(), - profileID: BrowserProfileStore.shared.builtInDefaultProfileID - ) - let staleWebView = panel.webView - let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) - let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) - staleWebView.loadHTMLString( - "Stalestale", - baseURL: staleURL - ) - - XCTAssertTrue( - panel.switchToProfile(alternateProfile.id), - "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" - ) - defaultStore.clearHistory() - alternateStore.clearHistory() - - staleDelegate.webView?(staleWebView, didFinish: nil) - drainMainQueue() - - XCTAssertTrue( - defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" - ) - XCTAssertTrue( - alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" - ) - } -} - -@MainActor -final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { - func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { - let manager = TabManager() - guard let workspace1 = manager.selectedWorkspace, - let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else { - XCTFail("Expected initial workspace and browser panel") - return - } - - drainMainQueue() - XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) - drainMainQueue() - - let workspace2 = manager.addWorkspace() - XCTAssertEqual(manager.selectedTabId, workspace2.id) - - XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) - drainMainQueue() - - XCTAssertEqual(manager.selectedTabId, workspace1.id) - XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) - } - - func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() { - let manager = TabManager() - guard let originalWorkspace = manager.selectedWorkspace, - let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else { - XCTFail("Expected initial workspace and browser panel") - return - } - - drainMainQueue() - XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true)) - drainMainQueue() - - let currentWorkspace = manager.addWorkspace() - manager.closeWorkspace(originalWorkspace) - - XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) - XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id })) - - XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) - drainMainQueue() - - XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) - XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace)) - } - - func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() { - let manager = TabManager() - guard let workspace1 = manager.selectedWorkspace, - let sourcePanelId = workspace1.focusedPanelId, - let splitBrowserId = manager.newBrowserSplit( - tabId: workspace1.id, - fromPanelId: sourcePanelId, - orientation: .horizontal, - insertFirst: false, - url: URL(string: "https://example.com/collapsed-split") - ) else { - XCTFail("Expected to create browser split") - return - } - - drainMainQueue() - XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true)) - drainMainQueue() - - let workspace2 = manager.addWorkspace() - XCTAssertEqual(manager.selectedTabId, workspace2.id) - - XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) - drainMainQueue() - - XCTAssertEqual(manager.selectedTabId, workspace1.id) - XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) - } - - func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() { - let manager = TabManager() - guard let workspace1 = manager.selectedWorkspace, - let preReopenPanelId = workspace1.focusedPanelId, - let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else { - XCTFail("Expected initial workspace state and browser panel") - return - } - - drainMainQueue() - XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) - drainMainQueue() - - let panelIdsBeforeReopen = Set(workspace1.panels.keys) - let workspace2 = manager.addWorkspace() - XCTAssertEqual(manager.selectedTabId, workspace2.id) - - XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) - guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else { - XCTFail("Expected reopened browser panel ID") - return - } - - // Simulate one delayed stale focus callback from the panel that was focused before reopen. - DispatchQueue.main.async { - workspace1.focusPanel(preReopenPanelId) - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.selectedTabId, workspace1.id) - XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId) - XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel) - } - - func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let preReopenPanelId = workspace.focusedPanelId, - let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else { - XCTFail("Expected initial workspace state and browser panel") - return - } - - drainMainQueue() - XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true)) - drainMainQueue() - - let panelIdsBeforeReopen = Set(workspace.panels.keys) - XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) - guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else { - XCTFail("Expected reopened browser panel ID") - return - } - - // Simulate one delayed stale focus callback from the panel that was focused before reopen. - DispatchQueue.main.async { - workspace.focusPanel(preReopenPanelId) - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.selectedTabId, workspace.id) - XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId) - XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel) - } - - private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool { - guard let focusedPanelId = workspace.focusedPanelId else { return false } - return workspace.panels[focusedPanelId] is BrowserPanel - } - - private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set) -> UUID? { - let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds) - guard newPanelIds.count == 1 else { return nil } - return newPanelIds.first - } - - private func drainMainQueue() { - let expectation = expectation(description: "drain main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) - } -} - -@MainActor -final class WorkspacePanelGitBranchTests: XCTestCase { - private func drainMainQueue() { - let expectation = expectation(description: "drain main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) - } - - func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected browser split panel to be created") - return - } - - drainMainQueue() - - XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus browser split to preserve pre-split focus" - ) - } - - func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - - guard let terminalSplitPanel = workspace.newTerminalSplit( - from: originalFocusedPanelId, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected terminal split panel to be created") - return - } - - drainMainQueue() - - XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus terminal split to preserve pre-split focus" - ) - } - - func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() { - let workspace = Workspace() - guard let panelId = workspace.focusedPanelId, - let paneId = workspace.paneId(forPanelId: panelId) else { - XCTFail("Expected initial panel and pane") - return - } - - XCTAssertEqual(workspace.panels.count, 1) -#if DEBUG - let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount -#endif - - guard let detached = workspace.detachSurface(panelId: panelId) else { - XCTFail("Expected detach of last surface to succeed") - return - } - - XCTAssertEqual(detached.panelId, panelId) - XCTAssertTrue( - workspace.panels.isEmpty, - "Detaching the last surface should not auto-create a replacement panel" - ) - XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) - XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) - - drainMainQueue() - drainMainQueue() -#if DEBUG - XCTAssertEqual( - workspace.debugFocusReconcileScheduledDuringDetachCount, - baselineFocusReconcileDuringDetach, - "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" - ) -#endif - - let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) - XCTAssertEqual(restoredPanelId, panelId) - XCTAssertEqual(workspace.panels.count, 1) - } - - func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { - let workspace = Workspace() - guard let originalPanelId = workspace.focusedPanelId, - let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { - XCTFail("Expected two panels before detach") - return - } - - drainMainQueue() - drainMainQueue() -#if DEBUG - let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount -#endif - - guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { - XCTFail("Expected detach to succeed") - return - } - - XCTAssertEqual(detached.panelId, movedPanel.id) - XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") - XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") - - drainMainQueue() - drainMainQueue() -#if DEBUG - XCTAssertEqual( - workspace.debugFocusReconcileScheduledDuringDetachCount, - baselineFocusReconcileDuringDetach, - "Detaching into another workspace should not enqueue delayed source focus reconciliation" - ) -#endif - } - - func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { - let source = Workspace() - guard let panelId = source.focusedPanelId else { - XCTFail("Expected source focused panel") - return - } - - XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) - - guard let detached = source.detachSurface(panelId: panelId) else { - XCTFail("Expected detach to succeed") - return - } - - XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") - XCTAssertNil(detached.customTitle) - XCTAssertEqual( - detached.title, - "detached-runtime-title", - "Detached transfer should carry the cached non-custom title" - ) - - let destination = Workspace() - guard let destinationPane = destination.bonsplitController.allPaneIds.first else { - XCTFail("Expected destination pane") - return - } - - let attachedPanelId = destination.attachDetachedSurface( - detached, - inPane: destinationPane, - focus: false - ) - XCTAssertEqual(attachedPanelId, panelId) - XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") - - guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), - let attachedTab = destination.bonsplitController.tab(attachedTabId) else { - XCTFail("Expected attached tab mapping") - return - } - XCTAssertEqual(attachedTab.title, "detached-runtime-title") - XCTAssertFalse(attachedTab.hasCustomTitle) - } - - func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { - XCTFail("Expected focused pane for initial panel") - return - } - - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected browser split panel to be created") - return - } - guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), - let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), - let splitTab = workspace.bonsplitController - .tabs(inPane: splitPaneId) - .first(where: { $0.id == splitTabId }) else { - XCTFail("Expected split pane/tab mapping") - return - } - - // Simulate one delayed stale split-selection callback from bonsplit. - DispatchQueue.main.async { - workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus split to reassert the pre-split focused panel" - ) - XCTAssertEqual( - workspace.bonsplitController.focusedPaneId, - originalPaneId, - "Expected focused pane to converge back to the pre-split pane" - ) - XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), - "Expected selected tab to converge back to the pre-split focused panel" - ) - } - - func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - - guard let browserSplitPanel = workspace.newBrowserSplit( - from: originalFocusedPanelId, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected browser split panel to be created") - return - } - - workspace.focusPanel(browserSplitPanel.id) - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual( - workspace.focusedPanelId, - browserSplitPanel.id, - "Expected explicit focus intent to keep the split panel focused" - ) - } - - func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId, - let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { - XCTFail("Expected initial focused panel and pane") - return - } - - guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { - XCTFail("Expected terminal surface to be created") - return - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus terminal surface creation to preserve the existing focused panel" - ) - XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), - "Expected selected tab to stay on the original focused panel" - ) - } - - func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId, - let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { - XCTFail("Expected initial focused panel and pane") - return - } - - guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { - XCTFail("Expected browser surface to be created") - return - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus browser surface creation to preserve the existing focused panel" - ) - XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), - "Expected selected tab to stay on the original focused panel" - ) - } - - func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { - let workspace = Workspace() - guard let firstPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - - workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) - guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else { - XCTFail("Expected split panel to be created") - return - } - - workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true) - XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused") - XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix") - XCTAssertEqual(workspace.gitBranch?.isDirty, true) - - XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed") - XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused") - XCTAssertEqual(workspace.gitBranch?.branch, "main") - XCTAssertEqual(workspace.gitBranch?.isDirty, false) - } - - func testSidebarGitBranchesFollowLeftToRightSplitOrder() { - let workspace = Workspace() - guard let leftPanelId = workspace.focusedPanelId else { - XCTFail("Expected initial focused panel") - return - } - - workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false) - guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected split panel to be created") - return - } - workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true) - - let ordered = workspace.sidebarGitBranchesInDisplayOrder() - XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"]) - XCTAssertEqual(ordered.map(\.isDirty), [false, true]) - } - - func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() { - let workspace = Workspace() - guard let leftFirstPanelId = workspace.focusedPanelId, - let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), - let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), - let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), - let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), - let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { - XCTFail("Expected panes and panels for ordering test") - return - } - - XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0)) - XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1)) - XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0)) - XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1)) - - workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) - workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false) - workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true) - workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false) - - XCTAssertEqual( - workspace.sidebarOrderedPanelIds(), - [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id] - ) - - let branches = workspace.sidebarGitBranchesInDisplayOrder() - XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"]) - XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) - } - - func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() { - let workspace = Workspace() - guard let leftFirstPanelId = workspace.focusedPanelId, - let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), - let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), - let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), - let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), - let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { - XCTFail("Expected panes and panels for precomputed ordering test") - return - } - - workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) - workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true) - workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false) - - workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root") - workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature") - workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root") - workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra") - - workspace.updatePanelPullRequest( - panelId: leftFirstPanelId, - number: 101, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!, - status: .open - ) - workspace.updatePanelPullRequest( - panelId: rightFirstPanel.id, - number: 18, - label: "MR", - url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!, - status: .merged - ) - - let orderedPanelIds = workspace.sidebarOrderedPanelIds() - - XCTAssertEqual( - workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" }, - workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" } - ) - XCTAssertEqual( - workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds), - workspace.sidebarBranchDirectoryEntriesInDisplayOrder() - ) - XCTAssertEqual( - workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds), - workspace.sidebarPullRequestsInDisplayOrder() - ) - } - - func testClosingPaneDropsBranchesFromClosedSide() { - let workspace = Workspace() - guard let leftPanelId = workspace.focusedPanelId, - let leftPaneId = workspace.paneId(forPanelId: leftPanelId), - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected left/right split panes") - return - } - - workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false) - workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false) - - XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"]) - XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) - XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"]) - } -} - -final class SidebarBranchOrderingTests: XCTestCase { - - func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() { - let first = UUID() - let second = UUID() - let third = UUID() - - let branches = SidebarBranchOrdering.orderedUniqueBranches( - orderedPanelIds: [first, second, third], - panelBranches: [ - first: SidebarGitBranchState(branch: "main", isDirty: false), - second: SidebarGitBranchState(branch: "feature", isDirty: false), - third: SidebarGitBranchState(branch: "main", isDirty: true) - ], - fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) - ) - - XCTAssertEqual( - branches, - [ - SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true), - SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false) - ] - ) - } - - func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() { - let branches = SidebarBranchOrdering.orderedUniqueBranches( - orderedPanelIds: [], - panelBranches: [:], - fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true) - ) - - XCTAssertEqual( - branches, - [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)] - ) - } - - func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() { - let first = UUID() - let second = UUID() - let third = UUID() - let fourth = UUID() - let fifth = UUID() - - let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [first, second, third, fourth, fifth], - panelBranches: [ - first: SidebarGitBranchState(branch: "main", isDirty: false), - second: SidebarGitBranchState(branch: "feature", isDirty: false), - third: SidebarGitBranchState(branch: "main", isDirty: true), - fourth: SidebarGitBranchState(branch: "main", isDirty: false) - ], - panelDirectories: [ - first: "/repo/a", - second: "/repo/b", - third: "/repo/a", - fourth: "/repo/d", - fifth: "/repo/e" - ], - defaultDirectory: "/repo/default", - fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) - ) - - XCTAssertEqual( - rows, - [ - SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"), - SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"), - SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"), - SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e") - ] - ) - } - - func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() { - let first = UUID() - let second = UUID() - - let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [first, second], - panelBranches: [:], - panelDirectories: [ - first: "/repo/one", - second: "/repo/two" - ], - defaultDirectory: "/repo/default", - fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true) - ) - - XCTAssertEqual( - rows, - [ - SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"), - SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two") - ] - ) - } - - func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() { - let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [], - panelBranches: [:], - panelDirectories: [:], - defaultDirectory: "/repo/default", - fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false) - ) - - XCTAssertEqual( - rows, - [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] - ) - } - - func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { - let first = UUID() - let second = UUID() - let third = UUID() - let fourth = UUID() - - let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: [first, second, third, fourth], - panelPullRequests: [ - first: pullRequestState( - number: 337, - label: "PR", - url: "https://github.com/manaflow-ai/cmux/pull/337", - status: .open - ), - second: pullRequestState( - number: 18, - label: "MR", - url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", - status: .open - ), - third: pullRequestState( - number: 337, - label: "PR", - url: "https://github.com/manaflow-ai/cmux/pull/337", - status: .merged - ), - fourth: pullRequestState( - number: 92, - label: "PR", - url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", - status: .closed - ) - ], - fallbackPullRequest: pullRequestState( - number: 1, - label: "PR", - url: "https://example.invalid/fallback/1", - status: .open - ) - ) - - XCTAssertEqual( - pullRequests.map { "\($0.label)#\($0.number)" }, - ["PR#337", "MR#18", "PR#92"] - ) - XCTAssertEqual( - pullRequests.map(\.status), - [.merged, .open, .closed] - ) - } - - func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { - let first = UUID() - let second = UUID() - - let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: [first, second], - panelPullRequests: [ - first: pullRequestState( - number: 42, - label: "PR", - url: "https://github.com/manaflow-ai/cmux/pull/42", - status: .open - ), - second: pullRequestState( - number: 42, - label: "MR", - url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", - status: .open - ) - ], - fallbackPullRequest: nil - ) - - XCTAssertEqual( - pullRequests.map { "\($0.label)#\($0.number)" }, - ["PR#42", "MR#42"] - ) - } - - func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { - let first = UUID() - let second = UUID() - - let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: [first, second], - panelPullRequests: [ - first: pullRequestState( - number: 42, - label: "PR", - url: "https://github.com/manaflow-ai/cmux/pull/42", - status: .open - ), - second: pullRequestState( - number: 42, - label: "PR", - url: "https://github.com/manaflow-ai/other-repo/pull/42", - status: .open - ) - ], - fallbackPullRequest: nil - ) - - XCTAssertEqual( - pullRequests.map(\.url.absoluteString), - [ - "https://github.com/manaflow-ai/cmux/pull/42", - "https://github.com/manaflow-ai/other-repo/pull/42" - ] - ) - } - - func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { - let fallback = pullRequestState( - number: 11, - label: "PR", - url: "https://github.com/manaflow-ai/cmux/pull/11", - status: .open - ) - let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( - orderedPanelIds: [], - panelPullRequests: [:], - fallbackPullRequest: fallback - ) - - XCTAssertEqual(pullRequests, [fallback]) - } - - private func pullRequestState( - number: Int, - label: String, - url: String, - status: SidebarPullRequestStatus - ) -> SidebarPullRequestState { - SidebarPullRequestState( - number: number, - label: label, - url: URL(string: url)!, - status: status - ) - } -} - -@MainActor -final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { - func testRequestPersistsUntilAcknowledged() { - let panel = BrowserPanel(workspaceId: UUID()) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - - let requestId = panel.requestAddressBarFocus() - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) - XCTAssertTrue(panel.shouldSuppressWebViewFocus()) - - panel.acknowledgeAddressBarFocusRequest(requestId) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - - // Acknowledgement only clears the durable request; focus suppression follows - // explicit blur state transitions. - XCTAssertTrue(panel.shouldSuppressWebViewFocus()) - panel.endSuppressWebViewFocusForAddressBar() - XCTAssertFalse(panel.shouldSuppressWebViewFocus()) - } - - func testRequestCoalescesWhilePending() { - let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus() - let secondRequest = panel.requestAddressBarFocus() - - XCTAssertEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) - } - - func testStaleAcknowledgementDoesNotClearNewestRequest() { - let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus() - panel.acknowledgeAddressBarFocusRequest(firstRequest) - let secondRequest = panel.requestAddressBarFocus() - - XCTAssertNotEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) - - panel.acknowledgeAddressBarFocusRequest(firstRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) - - panel.acknowledgeAddressBarFocusRequest(secondRequest) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - } -} - -final class SidebarDropPlannerTests: XCTestCase { - func testNoIndicatorForNoOpEdges() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: first, - targetTabId: first, - tabIds: tabIds, - pinnedTabIds: [] - ) - ) - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: nil, - tabIds: tabIds, - pinnedTabIds: [] - ) - ) - } - - func testNoIndicatorWhenOnlyOneTabExists() { - let only = UUID() - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: only, - targetTabId: nil, - tabIds: [only], - pinnedTabIds: [] - ) - ) - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: only, - targetTabId: only, - tabIds: [only], - pinnedTabIds: [] - ) - ) - } - - func testIndicatorAppearsForRealMoveToEnd() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - let indicator = SidebarDropPlanner.indicator( - draggedTabId: second, - targetTabId: nil, - tabIds: tabIds, - pinnedTabIds: [] - ) - XCTAssertEqual(indicator?.tabId, nil) - XCTAssertEqual(indicator?.edge, .bottom) - } - - func testTargetIndexForMoveToEndFromMiddle() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - let index = SidebarDropPlanner.targetIndex( - draggedTabId: second, - targetTabId: nil, - indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), - tabIds: tabIds, - pinnedTabIds: [] - ) - XCTAssertEqual(index, 2) - } - - func testNoIndicatorForSelfDropInMiddle() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: second, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [] - ) - ) - } - - func testPointerEdgeTopCanSuppressNoOpWhenDraggingFirstOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: first, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [], - pointerY: 2, - targetHeight: 40 - ) - ) - } - - func testPointerEdgeBottomAllowsMoveWhenDraggingFirstOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - let indicator = SidebarDropPlanner.indicator( - draggedTabId: first, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - XCTAssertEqual(indicator?.tabId, third) - XCTAssertEqual(indicator?.edge, .top) - XCTAssertEqual( - SidebarDropPlanner.targetIndex( - draggedTabId: first, - targetTabId: second, - indicator: indicator, - tabIds: tabIds, - pinnedTabIds: [] - ), - 1 - ) - } - - func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - let fromBottomOfFirst = SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: first, - tabIds: tabIds, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - let fromTopOfSecond = SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [], - pointerY: 2, - targetHeight: 40 - ) - - XCTAssertEqual(fromBottomOfFirst?.tabId, second) - XCTAssertEqual(fromBottomOfFirst?.edge, .top) - XCTAssertEqual(fromTopOfSecond?.tabId, second) - XCTAssertEqual(fromTopOfSecond?.edge, .top) - } - - func testPointerEdgeBottomSuppressesNoOpWhenDraggingLastOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let tabIds = [first, second, third] - - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - ) - } - - func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { - let pinnedA = UUID() - let pinnedB = UUID() - let unpinnedA = UUID() - let unpinnedB = UUID() - let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] - let pinnedIds: Set = [pinnedA, pinnedB] - - let indicator = SidebarDropPlanner.indicator( - draggedTabId: unpinnedB, - targetTabId: pinnedA, - tabIds: tabIds, - pinnedTabIds: pinnedIds, - pointerY: 2, - targetHeight: 40 - ) - - XCTAssertEqual(indicator?.tabId, unpinnedA) - XCTAssertEqual(indicator?.edge, .top) - } - - func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { - let pinnedA = UUID() - let pinnedB = UUID() - let unpinnedA = UUID() - let unpinnedB = UUID() - let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] - let pinnedIds: Set = [pinnedA, pinnedB] - - let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: unpinnedB, - targetTabId: pinnedA, - indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), - tabIds: tabIds, - pinnedTabIds: pinnedIds - ) - - XCTAssertEqual(targetIndex, 2) - } - -} - -final class SidebarDragAutoScrollPlannerTests: XCTestCase { - func testAutoScrollPlanTriggersNearTopAndBottomOnly() { - let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12) - XCTAssertEqual(topPlan?.direction, .up) - XCTAssertNotNil(topPlan) - - let bottomPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 96, distanceToBottom: 4, edgeInset: 44, minStep: 2, maxStep: 12) - XCTAssertEqual(bottomPlan?.direction, .down) - XCTAssertNotNil(bottomPlan) - - XCTAssertNil( - SidebarDragAutoScrollPlanner.plan(distanceToTop: 60, distanceToBottom: 60, edgeInset: 44, minStep: 2, maxStep: 12) - ) - } - - func testAutoScrollPlanSpeedsUpCloserToEdge() { - let nearTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 1, distanceToBottom: 99, edgeInset: 44, minStep: 2, maxStep: 12) - let midTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 22, distanceToBottom: 78, edgeInset: 44, minStep: 2, maxStep: 12) - - XCTAssertNotNil(nearTop) - XCTAssertNotNil(midTop) - XCTAssertGreaterThan(nearTop?.pointsPerTick ?? 0, midTop?.pointsPerTick ?? 0) - } - - func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() { - let aboveTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: -500, distanceToBottom: 600, edgeInset: 44, minStep: 2, maxStep: 12) - XCTAssertEqual(aboveTop?.direction, .up) - XCTAssertEqual(aboveTop?.pointsPerTick, 12) - - let belowBottom = SidebarDragAutoScrollPlanner.plan(distanceToTop: 600, distanceToBottom: -500, edgeInset: 44, minStep: 2, maxStep: 12) - XCTAssertEqual(belowBottom?.direction, .down) - XCTAssertEqual(belowBottom?.pointsPerTick, 12) - } -} - -final class FinderServicePathResolverTests: XCTestCase { - func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() { - let input: [URL] = [ - URL(fileURLWithPath: "/tmp/cmux-services/project", isDirectory: true), - URL(fileURLWithPath: "/tmp/cmux-services/project/README.md", isDirectory: false), - URL(fileURLWithPath: "/tmp/cmux-services/../cmux-services/project", isDirectory: true), - URL(fileURLWithPath: "/tmp/cmux-services/other", isDirectory: true), - ] - - let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input) - XCTAssertEqual( - directories, - [ - "/tmp/cmux-services/project", - "/tmp/cmux-services/other", - ] - ) - } - - func testOrderedUniqueDirectoriesPreservesFirstSeenOrder() { - let input: [URL] = [ - URL(fileURLWithPath: "/tmp/cmux-services/b", isDirectory: true), - URL(fileURLWithPath: "/tmp/cmux-services/a/file.txt", isDirectory: false), - URL(fileURLWithPath: "/tmp/cmux-services/a", isDirectory: true), - URL(fileURLWithPath: "/tmp/cmux-services/b/file.txt", isDirectory: false), - ] - - let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input) - XCTAssertEqual( - directories, - [ - "/tmp/cmux-services/b", - "/tmp/cmux-services/a", - ] - ) - } -} - -final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { - private func environment( - existingPaths: Set, - homeDirectoryPath: String = "/Users/tester", - applicationPathsByName: [String: String] = [:] - ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { - TerminalDirectoryOpenTarget.DetectionEnvironment( - homeDirectoryPath: homeDirectoryPath, - fileExistsAtPath: { existingPaths.contains($0) }, - isExecutableFileAtPath: { existingPaths.contains($0) }, - applicationPathForName: { applicationPathsByName[$0] } - ) - } - - func testAvailableTargetsDetectSystemApplications() { - let env = environment( - existingPaths: [ - "/Applications/Visual Studio Code.app", - "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel", - "/System/Library/CoreServices/Finder.app", - "/System/Applications/Utilities/Terminal.app", - "/Applications/Zed Preview.app", - ] - ) - - let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) - XCTAssertTrue(availableTargets.contains(.vscode)) - XCTAssertTrue(availableTargets.contains(.finder)) - XCTAssertTrue(availableTargets.contains(.terminal)) - XCTAssertTrue(availableTargets.contains(.zed)) - XCTAssertFalse(availableTargets.contains(.cursor)) - } - - func testAvailableTargetsFallbackToUserApplications() { - let env = environment( - existingPaths: [ - "/Users/tester/Applications/Cursor.app", - "/Users/tester/Applications/Warp.app", - "/Users/tester/Applications/Android Studio.app", - ] - ) - - let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) - XCTAssertTrue(availableTargets.contains(.cursor)) - XCTAssertTrue(availableTargets.contains(.warp)) - XCTAssertTrue(availableTargets.contains(.androidStudio)) - XCTAssertFalse(availableTargets.contains(.vscode)) - } - - func testVSCodeInlineRequiresCodeTunnelExecutable() { - let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) - XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) - XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env)) - } - - func testITerm2DetectsLegacyBundleName() { - let env = environment(existingPaths: ["/Applications/iTerm.app"]) - XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) - } - - func testTowerDetected() { - let env = environment(existingPaths: ["/Applications/Tower.app"]) - XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) - } - - func testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() { - let vscodePath = "/Volumes/Tools/Code.app" - let env = environment( - existingPaths: [ - vscodePath, - "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", - ], - applicationPathsByName: [ - "Code": vscodePath, - ] - ) - - let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) - XCTAssertTrue(availableTargets.contains(.vscode)) - XCTAssertTrue(availableTargets.contains(.vscodeInline)) - } - - func testTowerDetectedViaApplicationLookupOutsideApplications() { - let towerPath = "/Volumes/Setapp/Tower.app" - let env = environment( - existingPaths: [towerPath], - applicationPathsByName: [ - "Tower": towerPath, - ] - ) - - XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) - } - - func testCommandPaletteShortcutsExcludeGenericIDEEntry() { - let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets - XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) - XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) - } -} - -final class VSCodeServeWebURLBuilderTests: XCTestCase { - func testExtractWebUIURLParsesServeWebOutput() { - let output = """ - * - * Visual Studio Code Server - * - Web UI available at http://127.0.0.1:5555?tkn=test-token - """ - - let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output) - XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token") - } - - func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() { - let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")! - - let url = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: baseURL, - directoryPath: "/Users/tester/Projects/cmux" - ) - - let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) - XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token") - XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux") - } - - func testOpenFolderURLReplacesExistingFolderQuery() { - let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")! - - let url = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: baseURL, - directoryPath: "/Users/tester/New Folder" - ) - - let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) - XCTAssertEqual( - components?.queryItems?.filter { $0.name == "folder" }.count, - 1 - ) - XCTAssertEqual( - components?.queryItems?.first(where: { $0.name == "folder" })?.value, - "/Users/tester/New Folder" - ) - } -} - -final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase { - func testLaunchConfigurationUsesCodeTunnelBinary() { - let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) - let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel" - - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: appURL, - baseEnvironment: [:], - isExecutableAtPath: { $0 == expectedExecutablePath } - ) - - XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath) - XCTAssertEqual(configuration?.argumentsPrefix, []) - XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1") - } - - func testLaunchConfigurationMapsNodeEnvironmentVariables() { - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), - baseEnvironment: [ - "PATH": "/usr/bin:/bin", - "NODE_OPTIONS": "--max-old-space-size=4096", - "NODE_REPL_EXTERNAL_MODULE": "module-name" - ], - isExecutableAtPath: { _ in true } - ) - - XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") - XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096") - XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name") - XCTAssertNil(configuration?.environment["NODE_OPTIONS"]) - XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"]) - } - - func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() { - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), - baseEnvironment: [ - "PATH": "/usr/bin:/bin", - "VSCODE_NODE_OPTIONS": "--stale", - "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module" - ], - isExecutableAtPath: { _ in true } - ) - - XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") - XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"]) - XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"]) - } -} - -final class ServeWebOutputCollectorTests: XCTestCase { - func testWaitForURLReturnsFalseAfterProcessExitSignal() { - let collector = ServeWebOutputCollector() - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - collector.markProcessExited() - } - - let start = Date() - let resolved = collector.waitForURL(timeoutSeconds: 1) - let elapsed = Date().timeIntervalSince(start) - - XCTAssertFalse(resolved) - XCTAssertLessThan(elapsed, 0.5) - } - - func testWaitForURLReturnsTrueWhenURLIsCollected() { - let collector = ServeWebOutputCollector() - let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n" - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - collector.append(Data(urlLine.utf8)) - } - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token") - } - - func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() { - let collector = ServeWebOutputCollector() - let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token" - - collector.append(Data(finalChunk.utf8)) - collector.markProcessExited() - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token") - } -} - -final class VSCodeServeWebControllerTests: XCTestCase { - func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() { - let firstLaunchStarted = expectation(description: "first launch started") - let firstCompletionCalled = expectation(description: "first generation completion called") - let secondCompletionCalled = expectation(description: "second generation completion called") - - let launchGate = DispatchSemaphore(value: 0) - let launchCallLock = NSLock() - var launchCallCount = 0 - - let controller = VSCodeServeWebController.makeForTesting { _, _ in - launchCallLock.lock() - launchCallCount += 1 - let callNumber = launchCallCount - launchCallLock.unlock() - - if callNumber == 1 { - firstLaunchStarted.fulfill() - _ = launchGate.wait(timeout: .now() + 1) - } - return nil - } - - let callbackLock = NSLock() - var firstGenerationCallbacks: [URL?] = [] - var secondGenerationCallbacks: [URL?] = [] - let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) - - controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in - callbackLock.lock() - firstGenerationCallbacks.append(url) - callbackLock.unlock() - firstCompletionCalled.fulfill() - } - - wait(for: [firstLaunchStarted], timeout: 1) - controller.stop() - - controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in - callbackLock.lock() - secondGenerationCallbacks.append(url) - callbackLock.unlock() - secondCompletionCalled.fulfill() - } - - launchGate.signal() - wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2) - - callbackLock.lock() - let firstSnapshot = firstGenerationCallbacks - let secondSnapshot = secondGenerationCallbacks - callbackLock.unlock() - - launchCallLock.lock() - let launchCalls = launchCallCount - launchCallLock.unlock() - - XCTAssertEqual(firstSnapshot.count, 1) - if firstSnapshot.count == 1 { - XCTAssertNil(firstSnapshot[0]) - } - XCTAssertEqual(secondSnapshot.count, 1) - if secondSnapshot.count == 1 { - XCTAssertNil(secondSnapshot[0]) - } - XCTAssertEqual(launchCalls, 2) - } - - func testStopRemovesOrphanedConnectionTokenFiles() throws { - let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tokenFileURL) } - try Data("token".utf8).write(to: tokenFileURL) - XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) - - let controller = VSCodeServeWebController.makeForTesting { _, _ in - XCTFail("Expected no launch") - return nil - } - controller.trackConnectionTokenFileForTesting(tokenFileURL) - - controller.stop() - - XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path)) - } -} - -final class BrowserSearchEngineTests: XCTestCase { - func testGoogleSearchURL() throws { - let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) - XCTAssertEqual(url.host, "www.google.com") - XCTAssertEqual(url.path, "/search") - XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) - } - - func testDuckDuckGoSearchURL() throws { - let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world")) - XCTAssertEqual(url.host, "duckduckgo.com") - XCTAssertEqual(url.path, "/") - XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) - } - - func testBingSearchURL() throws { - let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world")) - XCTAssertEqual(url.host, "www.bing.com") - XCTAssertEqual(url.path, "/search") - XCTAssertTrue(url.absoluteString.contains("q=hello%20world")) - } -} - -final class BrowserSearchSettingsTests: XCTestCase { - func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() { - let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) - XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) - } - - func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() { - let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) - XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) - - defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey) - XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults)) - } -} - -final class BrowserHistoryStoreTests: XCTestCase { - func testRecordVisitDedupesAndSuggests() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - - let fileURL = tempDir.appendingPathComponent("browser_history.json") - let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } - - let u1 = try XCTUnwrap(URL(string: "https://example.com/foo")) - let u2 = try XCTUnwrap(URL(string: "https://example.com/bar")) - - await MainActor.run { - store.recordVisit(url: u1, title: "Example Foo") - store.recordVisit(url: u2, title: "Example Bar") - store.recordVisit(url: u1, title: "Example Foo Updated") - } - - let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) } - XCTAssertEqual(suggestions.first?.url, "https://example.com/foo") - XCTAssertEqual(suggestions.first?.visitCount, 2) - XCTAssertEqual(suggestions.first?.title, "Example Foo Updated") - } - - func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - - let fileURL = tempDir.appendingPathComponent("browser_history.json") - let now = Date() - let seededEntries = [ - BrowserHistoryStore.Entry( - id: UUID(), - url: "https://go.dev/", - title: "The Go Programming Language", - lastVisited: now, - visitCount: 3 - ), - BrowserHistoryStore.Entry( - id: UUID(), - url: "https://www.google.com/", - title: "Google", - lastVisited: now.addingTimeInterval(-120), - visitCount: 2 - ), - ] - - let encoder = JSONEncoder() - encoder.outputFormatting = [.withoutEscapingSlashes] - let data = try encoder.encode(seededEntries) - try data.write(to: fileURL, options: [.atomic]) - - let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) } - let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) } - - XCTAssertGreaterThanOrEqual(suggestions.count, 2) - XCTAssertEqual(suggestions.first?.url, "https://go.dev/") - XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" })) - } -} - -final class OmnibarStateMachineTests: XCTestCase { - func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws { - var state = OmnibarState() - - var effects = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - XCTAssertTrue(state.isFocused) - XCTAssertEqual(state.buffer, "https://example.com/") - XCTAssertFalse(state.isUserEditing) - XCTAssertTrue(effects.shouldSelectAll) - - effects = omnibarReduce(state: &state, event: .bufferChanged("exam")) - XCTAssertTrue(state.isUserEditing) - XCTAssertEqual(state.buffer, "exam") - XCTAssertTrue(effects.shouldRefreshSuggestions) - - // Simulate an open popup. - effects = omnibarReduce( - state: &state, - event: .suggestionsUpdated([.search(engineName: "Google", query: "exam")]) - ) - XCTAssertEqual(state.suggestions.count, 1) - XCTAssertFalse(effects.shouldSelectAll) - - // First escape: revert + close popup + select-all. - effects = omnibarReduce(state: &state, event: .escape) - XCTAssertEqual(state.buffer, "https://example.com/") - XCTAssertFalse(state.isUserEditing) - XCTAssertTrue(state.suggestions.isEmpty) - XCTAssertTrue(effects.shouldSelectAll) - XCTAssertFalse(effects.shouldBlurToWebView) - - // Second escape: blur (since we're not editing and popup is closed). - effects = omnibarReduce(state: &state, event: .escape) - XCTAssertTrue(effects.shouldBlurToWebView) - } - - func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws { - var state = OmnibarState() - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("hello")) - XCTAssertTrue(state.isUserEditing) - - _ = omnibarReduce(state: &state, event: .panelURLChanged(currentURLString: "https://b.test/")) - XCTAssertEqual(state.currentURLString, "https://b.test/") - XCTAssertEqual(state.buffer, "hello") - XCTAssertTrue(state.isUserEditing) - - let effects = omnibarReduce(state: &state, event: .escape) - XCTAssertEqual(state.buffer, "https://b.test/") - XCTAssertTrue(effects.shouldSelectAll) - } - - func testFocusLostRevertsUnlessSuppressed() throws { - var state = OmnibarState() - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("typed")) - XCTAssertEqual(state.buffer, "typed") - - _ = omnibarReduce(state: &state, event: .focusLostPreserveBuffer(currentURLString: "https://example.com/")) - XCTAssertEqual(state.buffer, "typed") - - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("typed2")) - _ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/")) - XCTAssertEqual(state.buffer, "https://example.com/") - } - - func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws { - var state = OmnibarState() - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("go")) - - let base: [OmnibarSuggestion] = [ - .search(engineName: "Google", query: "go"), - .remoteSearchSuggestion("go tutorial"), - .remoteSearchSuggestion("go json"), - ] - _ = omnibarReduce(state: &state, event: .suggestionsUpdated(base)) - XCTAssertEqual(state.selectedSuggestionIndex, 0) - - _ = omnibarReduce(state: &state, event: .moveSelection(delta: 2)) - XCTAssertEqual(state.selectedSuggestionIndex, 2) - - // Simulate remote merge update for the same query while popup remains open. - let merged: [OmnibarSuggestion] = [ - .search(engineName: "Google", query: "go"), - .remoteSearchSuggestion("go tutorial"), - .remoteSearchSuggestion("go json"), - .remoteSearchSuggestion("go fmt"), - ] - _ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged)) - XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open") - } - - func testSuggestionsReopenResetsSelectionToFirstRow() throws { - var state = OmnibarState() - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("go")) - - let rows: [OmnibarSuggestion] = [ - .search(engineName: "Google", query: "go"), - .remoteSearchSuggestion("go tutorial"), - ] - _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) - _ = omnibarReduce(state: &state, event: .moveSelection(delta: 1)) - XCTAssertEqual(state.selectedSuggestionIndex, 1) - - _ = omnibarReduce(state: &state, event: .suggestionsUpdated([])) - XCTAssertEqual(state.selectedSuggestionIndex, 0) - - _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) - XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row") - } - - func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws { - var state = OmnibarState() - _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) - _ = omnibarReduce(state: &state, event: .bufferChanged("gm")) - - let rows: [OmnibarSuggestion] = [ - .search(engineName: "Google", query: "gm"), - .history(url: "https://google.com/", title: "Google"), - .history(url: "https://gmail.com/", title: "Gmail"), - ] - _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) - XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.") - XCTAssertEqual(state.selectedSuggestionID, rows[2].id) - XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex])) - XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/") - } -} - -final class OmnibarRemoteSuggestionMergeTests: XCTestCase { - func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() { - let now = Date() - let entries: [BrowserHistoryStore.Entry] = [ - BrowserHistoryStore.Entry( - id: UUID(), - url: "https://go.dev/", - title: "The Go Programming Language", - lastVisited: now, - visitCount: 10 - ), - ] - - let merged = buildOmnibarSuggestions( - query: "go", - engineName: "Google", - historyEntries: entries, - openTabMatches: [], - remoteQueries: ["go tutorial", "go.dev", "go json"], - resolvedURL: nil, - limit: 8 - ) - - let completions = merged.compactMap { $0.completion } - XCTAssertGreaterThanOrEqual(completions.count, 5) - XCTAssertEqual(completions[0], "https://go.dev/") - XCTAssertEqual(completions[1], "go") - - let remoteCompletions = Array(completions.dropFirst(2)) - XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"])) - XCTAssertEqual(remoteCompletions.count, 3) - } - - func testStaleRemoteSuggestionsKeptForNearbyEdits() { - let stale = staleOmnibarRemoteSuggestionsForDisplay( - query: "go t", - previousRemoteQuery: "go", - previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"], - limit: 8 - ) - - XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"]) - } - - func testStaleRemoteSuggestionsTrimAndRespectLimit() { - let stale = staleOmnibarRemoteSuggestionsForDisplay( - query: "gooo", - previousRemoteQuery: "goo", - previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"], - limit: 2 - ) - - XCTAssertEqual(stale, ["go tutorial", "go json"]) - } - - func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() { - let stale = staleOmnibarRemoteSuggestionsForDisplay( - query: "python", - previousRemoteQuery: "go", - previousRemoteSuggestions: ["go tutorial", "go json"], - limit: 8 - ) - - XCTAssertTrue(stale.isEmpty) - } -} - -final class OmnibarSuggestionRankingTests: XCTestCase { - private var fixedNow: Date { - Date(timeIntervalSinceReferenceDate: 10_000_000) - } - - func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://news.ycombinator.com/", - title: "News.YC", - lastVisited: fixedNow, - visitCount: 12, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://www.google.com/", - title: "Google", - lastVisited: fixedNow - 200, - visitCount: 8, - typedCount: 2, - lastTypedAt: fixedNow - 200 - ), - ] - - let results = buildOmnibarSuggestions( - query: "n", - engineName: "Google", - historyEntries: entries, - openTabMatches: [], - remoteQueries: ["search google for n", "news"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/") - XCTAssertNotEqual(results.map(\.completion).first, "n") - XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false) - } - - func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://google.com/", - title: "Google", - lastVisited: fixedNow, - visitCount: 4, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://gmail.com/", - title: "Gmail", - lastVisited: fixedNow, - visitCount: 10, - typedCount: 2, - lastTypedAt: fixedNow - ), - ] - - let results = buildOmnibarSuggestions( - query: "gm", - engineName: "Google", - historyEntries: entries, - openTabMatches: [], - remoteQueries: ["gmail", "gmail.com", "google mail"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - XCTAssertEqual(results.first?.completion, "https://gmail.com/") - XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) - - let inlineCompletion = omnibarInlineCompletionForDisplay( - typedText: "gm", - suggestions: results, - isFocused: true, - selectionRange: NSRange(location: 2, length: 0), - hasMarkedText: false - ) - XCTAssertNotNil(inlineCompletion) - } - - func testAutocompletionCandidateWinsOverRemoteAndSearchRowsForTwoLetterQuery() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://google.com/", - title: "Google", - lastVisited: fixedNow, - visitCount: 4, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://gmail.com/", - title: "Gmail", - lastVisited: fixedNow, - visitCount: 10, - typedCount: 2, - lastTypedAt: fixedNow - ), - ] - - let results = buildOmnibarSuggestions( - query: "gm", - engineName: "Google", - historyEntries: entries, - openTabMatches: [ - .init( - tabId: UUID(), - panelId: UUID(), - url: "https://gmail.com/", - title: "Gmail", - isKnownOpenTab: true - ), - ], - remoteQueries: ["Search google for gm", "gmail", "gmail.com", "Google mail"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) - XCTAssertEqual(results.first?.completion, "https://gmail.com/") - } - - func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://google.com/", - title: "Google", - lastVisited: fixedNow, - visitCount: 4, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://gmail.com/", - title: "Gmail", - lastVisited: fixedNow, - visitCount: 10, - typedCount: 2, - lastTypedAt: fixedNow - ), - ] - - let results = buildOmnibarSuggestions( - query: "gm", - engineName: "Google", - historyEntries: entries, - openTabMatches: [], - remoteQueries: ["Search google for gm", "gmail", "gmail.com"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - var state = OmnibarState() - let _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "")) - let _ = omnibarReduce(state: &state, event: .bufferChanged("gm")) - let _ = omnibarReduce(state: &state, event: .suggestionsUpdated(results)) - - XCTAssertEqual(state.selectedSuggestionIndex, 0) - XCTAssertEqual(state.selectedSuggestionID, results[0].id) - XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[0])) - } - - func testTwoCharQueryWithRemoteSuggestionsStillPromotesAutocompletionMatch() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://news.ycombinator.com/", - title: "News.YC", - lastVisited: fixedNow, - visitCount: 12, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://www.google.com/", - title: "Google", - lastVisited: fixedNow - 200, - visitCount: 8, - typedCount: 2, - lastTypedAt: fixedNow - 200 - ), - ] - - let results = buildOmnibarSuggestions( - query: "ne", - engineName: "Google", - historyEntries: entries, - openTabMatches: [], - remoteQueries: ["netflix", "new york times", "newegg"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - // The autocompletable history entry (news.ycombinator.com) should be first despite remote results. - XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/") - XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "ne", suggestion: $0) } ?? false) - - // Remote suggestions should still appear in the results (two-char queries include them). - let remoteCompletions = results.filter { - if case .remote = $0.kind { return true } - return false - }.map(\.completion) - XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions to be present for two-char query") - } - - func testGmQueryWithRemoteSuggestionsAndOpenTabPromotesAutocompletionMatch() { - let entries: [BrowserHistoryStore.Entry] = [ - .init( - id: UUID(), - url: "https://google.com/", - title: "Google", - lastVisited: fixedNow, - visitCount: 4, - typedCount: 1, - lastTypedAt: fixedNow - ), - .init( - id: UUID(), - url: "https://gmail.com/", - title: "Gmail", - lastVisited: fixedNow, - visitCount: 10, - typedCount: 2, - lastTypedAt: fixedNow - ), - ] - - let results = buildOmnibarSuggestions( - query: "gm", - engineName: "Google", - historyEntries: entries, - openTabMatches: [ - .init( - tabId: UUID(), - panelId: UUID(), - url: "https://google.com/maps", - title: "Google Maps", - isKnownOpenTab: true - ), - ], - remoteQueries: ["gmail login", "gm stock price", "gmail.com"], - resolvedURL: nil, - limit: 8, - now: fixedNow - ) - - // Gmail should be first (autocompletable + typed history). - XCTAssertEqual(results.first?.completion, "https://gmail.com/") - XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) - - // Verify remote suggestions are present alongside history/tab matches. - let remoteCompletions = results.filter { - if case .remote = $0.kind { return true } - return false - }.map(\.completion) - XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions in results") - let hasSearch = results.contains { - if case .search = $0.kind { return true } - return false - } - XCTAssertTrue(hasSearch, "Expected search row in results") - } - - func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() { - let row = OmnibarSuggestion.history( - url: "https://www.example.com/path?q=1", - title: "Example Domain" - ) - XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1") - XCTAssertFalse(row.listText.contains("\n")) - } - - func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() { - let inline = OmnibarInlineCompletion( - typedText: "l", - displayText: "localhost:3000", - acceptedText: "https://localhost:3000/" - ) - - let published = omnibarPublishedBufferTextForFieldChange( - fieldValue: inline.displayText, - inlineCompletion: inline, - selectionRange: inline.suffixRange, - hasMarkedText: false - ) - - XCTAssertEqual(published, "l") - } - - func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() { - let inline = OmnibarInlineCompletion( - typedText: "l", - displayText: "localhost:3000", - acceptedText: "https://localhost:3000/" - ) - - let published = omnibarPublishedBufferTextForFieldChange( - fieldValue: "la", - inlineCompletion: inline, - selectionRange: NSRange(location: 2, length: 0), - hasMarkedText: false - ) - - XCTAssertEqual(published, "la") - } - - func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() { - let staleInline = OmnibarInlineCompletion( - typedText: "g", - displayText: "github.com", - acceptedText: "https://github.com/" - ) - - let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( - bufferText: "l", - inlineCompletion: staleInline - ) - - XCTAssertNil(active) - } - - func testInlineCompletionRenderKeepsMatchingTypedPrefix() { - let inline = OmnibarInlineCompletion( - typedText: "l", - displayText: "localhost:3000", - acceptedText: "https://localhost:3000/" - ) - - let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( - bufferText: "l", - inlineCompletion: inline - ) - - XCTAssertEqual(active, inline) - } - - func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() { - // History entry: visited google.com/search?q=localhost:3000 with title - // "localhost:3000 - Google Search". Typing "l" should NOT inline-complete - // to "google.com/..." because that replaces the typed "l" with "g". - let suggestions: [OmnibarSuggestion] = [ - .history( - url: "https://www.google.com/search?q=localhost:3000", - title: "localhost:3000 - Google Search" - ), - ] - - let result = omnibarInlineCompletionForDisplay( - typedText: "l", - suggestions: suggestions, - isFocused: true, - selectionRange: NSRange(location: 1, length: 0), - hasMarkedText: false - ) - - XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix") - } -} - -@MainActor -final class NotificationDockBadgeTests: XCTestCase { - private final class NotificationSettingsAlertSpy: NSAlert { - private(set) var beginSheetModalCallCount = 0 - private(set) var runModalCallCount = 0 - var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn - - override func beginSheetModal( - for sheetWindow: NSWindow, - completionHandler handler: ((NSApplication.ModalResponse) -> Void)? - ) { - beginSheetModalCallCount += 1 - handler?(nextResponse) - } - - override func runModal() -> NSApplication.ModalResponse { - runModalCallCount += 1 - return nextResponse - } - } - - override func tearDown() { - TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() - TerminalNotificationStore.shared.replaceNotificationsForTesting([]) - super.tearDown() - } - - func testDockBadgeLabelEnabledAndCounted() { - XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1") - XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42") - XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+") - } - - func testDockBadgeLabelHiddenWhenDisabledOrZero() { - XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true)) - XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false)) - } - - func testDockBadgeLabelShowsRunTagEvenWithoutUnread() { - XCTAssertEqual( - TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"), - "verify-tag" - ) - } - - func testDockBadgeLabelCombinesRunTagAndUnreadCount() { - XCTAssertEqual( - TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"), - "verify:7" - ) - XCTAssertEqual( - TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"), - "verify:99+" - ) - } - - func testNotificationBadgePreferenceDefaultsToEnabled() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) - - defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey) - XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) - - defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey) - XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) - } - - func testNotificationPaneFlashPreferenceDefaultsToEnabled() { - let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - - defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey) - XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - - defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey) - XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - } - - func testMenuBarExtraPreferenceDefaultsToVisible() { - let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) - - defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey) - XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) - - defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey) - XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) - } - - func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) - - defaults.set("Ping", forKey: NotificationSoundSettings.key) - XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) - XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) - } - - func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - defaults.set("none", forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) - XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) - XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - } - - func testNotificationCustomFileURLExpandsTildePath() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let rawPath = "~/Library/Sounds/my-custom.wav" - defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) - let expectedPath = (rawPath as NSString).expandingTildeInPath - XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) - } - - func testNotificationCustomFileSelectionMustBeExplicit() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) - - defaults.set("none", forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - - defaults.set("Ping", forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - } - - func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let fileManager = FileManager.default - let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - do { - try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) - } catch { - XCTFail("Failed to create sounds directory: \(error)") - return - } - - let sourceURL = soundsDirectory.appendingPathComponent( - "cmux-custom-notification-sound.source-\(UUID().uuidString).wav", - isDirectory: false - ) - defer { - try? fileManager.removeItem(at: sourceURL) - } - - do { - try Data("test".utf8).write(to: sourceURL, options: .atomic) - } catch { - XCTFail("Failed to write source custom sound file: \(error)") - return - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - _ = NotificationSoundSettings.sound(defaults: defaults) - - guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else { - XCTFail("Expected staged custom sound name") - return - } - let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) - defer { - try? fileManager.removeItem(at: stagedURL) - } - - XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) - XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-")) - XCTAssertTrue(stagedName.hasSuffix(".wav")) - } - - func testNotificationCustomUnsupportedExtensionsStageAsCaf() { - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"), - "caf" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"), - "caf" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"), - "wav" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"), - "aiff" - ) - - let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3") - let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3") - let stagedA = NotificationSoundSettings.stagedCustomSoundFileName( - forSourceURL: sourceA, - destinationExtension: "caf" - ) - let stagedB = NotificationSoundSettings.stagedCustomSoundFileName( - forSourceURL: sourceB, - destinationExtension: "caf" - ) - XCTAssertNotEqual(stagedA, stagedB) - XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-")) - XCTAssertTrue(stagedA.hasSuffix(".caf")) - } - - func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let fileManager = FileManager.default - let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - do { - try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) - } catch { - XCTFail("Failed to create sounds directory: \(error)") - return - } - - let sourceURL = soundsDirectory.appendingPathComponent( - "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav", - isDirectory: false - ) - do { - try Data("test".utf8).write(to: sourceURL, options: .atomic) - } catch { - XCTFail("Failed to write source custom sound file: \(error)") - return - } - defer { - try? fileManager.removeItem(at: sourceURL) - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path) - let stagedName: String - switch prepareResult { - case .success(let name): - stagedName = name - case .failure(let issue): - XCTFail("Expected custom sound preparation success, got \(issue)") - return - } - - let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) - let metadataURL = stagedURL.appendingPathExtension("source-metadata") - defer { - try? fileManager.removeItem(at: stagedURL) - try? fileManager.removeItem(at: metadataURL) - } - - XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path)) - } - - func testNotificationCustomSoundReturnsNilWhenPreparationFails() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let invalidSourceURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false) - defer { - try? FileManager.default.removeItem(at: invalidSourceURL) - let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false) - try? FileManager.default.removeItem(at: stagedURL) - } - - do { - try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic) - } catch { - XCTFail("Failed to write invalid custom sound source: \(error)") - return - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - } - - func testNotificationCustomPreparationReportsMissingFile() { - let missingPath = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false) - .path - - let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath) - switch result { - case .success: - XCTFail("Expected missing file failure") - case .failure(let issue): - guard case .missingFile = issue else { - XCTFail("Expected missingFile issue, got \(issue)") - return - } - } - } - - func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() { - XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined) - XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied) - XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized) - XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional) - } - - func testNotificationAuthorizationStateDeliveryCapability() { - XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery) - XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery) - XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery) - XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery) - XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery) - XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery) - } - - func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() { - XCTAssertTrue( - TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( - status: .notDetermined, - isAppActive: false - ) - ) - XCTAssertFalse( - TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( - status: .notDetermined, - isAppActive: true - ) - ) - XCTAssertFalse( - TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( - status: .authorized, - isAppActive: false - ) - ) - } - - func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() { - XCTAssertTrue( - TerminalNotificationStore.shouldRequestAuthorization( - isAutomaticRequest: false, - hasRequestedAutomaticAuthorization: true - ) - ) - XCTAssertTrue( - TerminalNotificationStore.shouldRequestAuthorization( - isAutomaticRequest: true, - hasRequestedAutomaticAuthorization: false - ) - ) - XCTAssertFalse( - TerminalNotificationStore.shouldRequestAuthorization( - isAutomaticRequest: true, - hasRequestedAutomaticAuthorization: true - ) - ) - } - - func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() { - let store = TerminalNotificationStore.shared - let alertSpy = NotificationSettingsAlertSpy() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - - var openedURL: URL? - store.configureNotificationSettingsPromptHooksForTesting( - windowProvider: { window }, - alertFactory: { alertSpy }, - scheduler: { _, block in block() }, - urlOpener: { openedURL = $0 } - ) - - store.promptToEnableNotificationsForTesting() - let drained = expectation(description: "main queue drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) - XCTAssertEqual(alertSpy.runModalCallCount, 0) - XCTAssertEqual( - openedURL?.absoluteString, - "x-apple.systempreferences:com.apple.preference.notifications" - ) - } - - func testNotificationSettingsPromptRetriesUntilWindowExists() { - let store = TerminalNotificationStore.shared - let alertSpy = NotificationSettingsAlertSpy() - alertSpy.nextResponse = .alertSecondButtonReturn - - var queuedRetryBlocks: [() -> Void] = [] - var promptWindow: NSWindow? - store.configureNotificationSettingsPromptHooksForTesting( - windowProvider: { promptWindow }, - alertFactory: { alertSpy }, - scheduler: { _, block in queuedRetryBlocks.append(block) }, - urlOpener: { _ in XCTFail("Should not open settings for Not Now response") } - ) - - store.promptToEnableNotificationsForTesting() - let drained = expectation(description: "main queue drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) - XCTAssertEqual(alertSpy.runModalCallCount, 0) - XCTAssertEqual(queuedRetryBlocks.count, 1) - - promptWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - queuedRetryBlocks.removeFirst()() - - XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) - XCTAssertEqual(alertSpy.runModalCallCount, 0) - } - - func testNotificationIndexesTrackUnreadCountsByTabAndSurface() { - let tabA = UUID() - let tabB = UUID() - let surfaceA = UUID() - let surfaceB = UUID() - let notificationAUnread = TerminalNotification( - id: UUID(), - tabId: tabA, - surfaceId: surfaceA, - title: "A unread", - subtitle: "", - body: "", - createdAt: Date(), - isRead: false - ) - let notificationARead = TerminalNotification( - id: UUID(), - tabId: tabA, - surfaceId: surfaceB, - title: "A read", - subtitle: "", - body: "", - createdAt: Date(), - isRead: true - ) - let notificationBUnread = TerminalNotification( - id: UUID(), - tabId: tabB, - surfaceId: nil, - title: "B unread", - subtitle: "", - body: "", - createdAt: Date(), - isRead: false - ) - - let store = TerminalNotificationStore.shared - store.replaceNotificationsForTesting([ - notificationAUnread, - notificationARead, - notificationBUnread - ]) - - XCTAssertEqual(store.unreadCount, 2) - XCTAssertEqual(store.unreadCount(forTabId: tabA), 1) - XCTAssertEqual(store.unreadCount(forTabId: tabB), 1) - XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA)) - XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB)) - XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil)) - XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id) - XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) - } - - func testNotificationIndexesUpdateAfterReadAndClearMutations() { - let tab = UUID() - let surfaceUnread = UUID() - let surfaceRead = UUID() - let unreadNotification = TerminalNotification( - id: UUID(), - tabId: tab, - surfaceId: surfaceUnread, - title: "Unread", - subtitle: "", - body: "", - createdAt: Date(), - isRead: false - ) - let readNotification = TerminalNotification( - id: UUID(), - tabId: tab, - surfaceId: surfaceRead, - title: "Read", - subtitle: "", - body: "", - createdAt: Date(), - isRead: true - ) - - let store = TerminalNotificationStore.shared - store.replaceNotificationsForTesting([unreadNotification, readNotification]) - XCTAssertEqual(store.unreadCount(forTabId: tab), 1) - XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) - - store.markRead(forTabId: tab, surfaceId: surfaceUnread) - XCTAssertEqual(store.unreadCount(forTabId: tab), 0) - XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) - XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id) - - store.clearNotifications(forTabId: tab) - XCTAssertEqual(store.unreadCount(forTabId: tab), 0) - XCTAssertNil(store.latestNotification(forTabId: tab)) - } -} - -@MainActor -final class TerminalNotificationDirectInteractionTests: XCTestCase { - private func makeWindow() -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) - return window - } - - private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create \(type) mouse event") - } - return event - } - - private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: characters, - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: keyCode - ) else { - fatalError("Failed to create key event") - } - return event - } - - private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { - hostedView.subviews - .compactMap { $0 as? NSScrollView } - .first? - .documentView? - .subviews - .first - } - - func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let window = makeWindow() - - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - let originalAppFocusOverride = AppFocusState.overrideIsFocused - - store.replaceNotificationsForTesting([]) - store.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - store.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - AppFocusState.overrideIsFocused = originalAppFocusOverride - window.orderOut(nil) - } - - guard let workspace = manager.selectedWorkspace, - let terminalPanel = workspace.focusedTerminalPanel else { - XCTFail("Expected an initial focused terminal panel") - return - } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let hostedView = terminalPanel.hostedView - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - contentView.layoutSubtreeIfNeeded() - hostedView.layoutSubtreeIfNeeded() - - guard let surfaceView = surfaceView(in: hostedView) else { - XCTFail("Expected terminal surface view") - return - } - - GhosttySurfaceScrollView.resetFlashCounts() - AppFocusState.overrideIsFocused = true - XCTAssertTrue(window.makeFirstResponder(surfaceView)) - - store.addNotification( - tabId: workspace.id, - surfaceId: terminalPanel.id, - title: "Unread", - subtitle: "", - body: "" - ) - XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - - AppFocusState.overrideIsFocused = true - let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) - let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) - surfaceView.mouseDown(with: event) - let drained = expectation(description: "flash drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) - } - - func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - let window = makeWindow() - - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - let originalAppFocusOverride = AppFocusState.overrideIsFocused - - store.replaceNotificationsForTesting([]) - store.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - store.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - AppFocusState.overrideIsFocused = originalAppFocusOverride - window.orderOut(nil) - } - - guard let workspace = manager.selectedWorkspace, - let terminalPanel = workspace.focusedTerminalPanel else { - XCTFail("Expected an initial focused terminal panel") - return - } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let hostedView = terminalPanel.hostedView - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - contentView.layoutSubtreeIfNeeded() - hostedView.layoutSubtreeIfNeeded() - - guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { - XCTFail("Expected terminal surface view") - return - } - - GhosttySurfaceScrollView.resetFlashCounts() - AppFocusState.overrideIsFocused = true - XCTAssertTrue(window.makeFirstResponder(surfaceView)) - - store.addNotification( - tabId: workspace.id, - surfaceId: terminalPanel.id, - title: "Unread", - subtitle: "", - body: "" - ) - XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - - let event = makeKeyEvent(characters: "", keyCode: 122, window: window) - surfaceView.keyDown(with: event) - let drained = expectation(description: "flash drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) - XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) - } -} - - -final class MenuBarBadgeLabelFormatterTests: XCTestCase { - func testBadgeLabelFormatting() { - XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0)) - XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1") - XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9") - XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+") - XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+") - } -} - -final class NotificationMenuSnapshotBuilderTests: XCTestCase { - func testSnapshotCountsUnreadAndLimitsRecentItems() { - let notifications = (0..<8).map { index in - TerminalNotification( - id: UUID(), - tabId: UUID(), - surfaceId: nil, - title: "N\(index)", - subtitle: "", - body: "", - createdAt: Date(timeIntervalSince1970: TimeInterval(index)), - isRead: index.isMultiple(of: 2) - ) - } - - let snapshot = NotificationMenuSnapshotBuilder.make( - notifications: notifications, - maxInlineNotificationItems: 3 - ) - - XCTAssertEqual(snapshot.unreadCount, 4) - XCTAssertTrue(snapshot.hasNotifications) - XCTAssertTrue(snapshot.hasUnreadNotifications) - XCTAssertEqual(snapshot.recentNotifications.count, 3) - XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id)) - } - - func testStateHintTitleHandlesSingularPluralAndZero() { - XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications") - XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification") - XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications") - } -} - -final class MenuBarBuildHintFormatterTests: XCTestCase { - func testReleaseBuildShowsNoHint() { - XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false)) - } - - func testDebugBuildWithTagShowsTag() { - XCTAssertEqual( - MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true), - "Build Tag: menubar-extra" - ) - } - - func testDebugBuildWithoutTagShowsUntagged() { - XCTAssertEqual( - MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true), - "Build: DEV (untagged)" - ) - } -} - -final class MenuBarNotificationLineFormatterTests: XCTestCase { - func testPlainTitleContainsUnreadDotBodyAndTab() { - let notification = TerminalNotification( - id: UUID(), - tabId: UUID(), - surfaceId: nil, - title: "Build finished", - subtitle: "", - body: "All checks passed", - createdAt: Date(timeIntervalSince1970: 0), - isRead: false - ) - - let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1") - XCTAssertTrue(line.hasPrefix("● Build finished")) - XCTAssertTrue(line.contains("All checks passed")) - XCTAssertTrue(line.contains("workspace-1")) - } - - func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() { - let notification = TerminalNotification( - id: UUID(), - tabId: UUID(), - surfaceId: nil, - title: "Deploy", - subtitle: "staging", - body: "", - createdAt: Date(timeIntervalSince1970: 0), - isRead: true - ) - - let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil) - XCTAssertTrue(line.hasPrefix(" Deploy")) - XCTAssertTrue(line.contains("staging")) - } - - func testMenuTitleWrapsAndTruncatesToThreeLines() { - let notification = TerminalNotification( - id: UUID(), - tabId: UUID(), - surfaceId: nil, - title: "Extremely long notification title for wrapping behavior validation", - subtitle: "", - body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "), - createdAt: Date(timeIntervalSince1970: 0), - isRead: false - ) - - let title = MenuBarNotificationLineFormatter.menuTitle( - notification: notification, - tabTitle: "workspace-with-a-very-long-name", - maxWidth: 120, - maxLines: 3 - ) - - XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3) - XCTAssertTrue(title.hasSuffix("…")) - } - - func testMenuTitlePreservesShortTextWithoutEllipsis() { - let notification = TerminalNotification( - id: UUID(), - tabId: UUID(), - surfaceId: nil, - title: "Done", - subtitle: "", - body: "All checks passed", - createdAt: Date(timeIntervalSince1970: 0), - isRead: false - ) - - let title = MenuBarNotificationLineFormatter.menuTitle( - notification: notification, - tabTitle: "w1", - maxWidth: 320, - maxLines: 3 - ) - - XCTAssertFalse(title.hasSuffix("…")) - } -} - - -final class MenuBarIconDebugSettingsTests: XCTestCase { - func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() { - let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey) - defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey) - - XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7) - } - - func testBadgeRenderConfigClampsInvalidValues() { - let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey) - defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey) - defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey) - defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey) - - let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) - XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001) - XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001) - XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001) - XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001) - } - - func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() { - let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey) - - let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) - XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001) - } -} - -@MainActor - -final class MenuBarIconRendererTests: XCTestCase { - func testImageWidthDoesNotShiftWhenBadgeAppears() { - let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0) - let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2) - - XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001) - XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) - } -} - -final class WorkspaceMountPolicyTests: XCTestCase { - func testDefaultPolicyMountsOnlySelectedWorkspace() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces - ) - - XCTAssertEqual(next, [b]) - } - - func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b, c], - selected: c, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [c, a]) - } - - func testMissingWorkspacesArePruned() { - let a = UUID() - let b = UUID() - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [b, a], - selected: nil, - pinnedIds: [], - orderedTabIds: [a], - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [a]) - } - - func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [b, a]) - } - - func testMaxMountedIsClampedToAtLeastOne() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b], - selected: nil, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 0 - ) - - XCTAssertEqual(next, [a]) - } - - func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() { - let a = UUID() - let b = UUID() - let c = UUID() - let d = UUID() - let orderedTabIds: [UUID] = [a, b, c, d] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: c, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle - ) - - XCTAssertEqual(next, [c]) - } - - func testCycleHotModeRespectsMaxMountedLimit() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a, b, c], - selected: b, - pinnedIds: [], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: 2 - ) - - XCTAssertEqual(next, [b]) - } - - func testPinnedIdsAreRetainedAcrossReconcile() { - let a = UUID() - let b = UUID() - let c = UUID() - let orderedTabIds: [UUID] = [a, b, c] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: c, - pinnedIds: [a], - orderedTabIds: orderedTabIds, - isCycleHot: false, - maxMounted: 2 - ) - - XCTAssertEqual(next, [c, a]) - } - - func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() { - let a = UUID() - let b = UUID() - let orderedTabIds: [UUID] = [a, b] - - let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( - current: [a], - selected: b, - pinnedIds: [a], - orderedTabIds: orderedTabIds, - isCycleHot: true, - maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle - ) - - XCTAssertEqual(next, [b, a]) - } -} - -@MainActor -final class WindowTerminalHostViewTests: XCTestCase { - private final class CapturingView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} - - func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { - let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) - - XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10))) - } - - func testHostViewReturnsSubviewWhenSubviewIsHit() { - let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) - let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30)) - host.addSubview(child) - - XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child) - XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) - } - - func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let splitView = NSSplitView(frame: contentView.bounds) - splitView.autoresizingMask = [.width, .height] - splitView.isVertical = true - splitView.dividerStyle = .thin - let splitDelegate = BonsplitMockSplitDelegate() - splitView.delegate = splitDelegate - let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) - let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) - splitView.addSubview(first) - splitView.addSubview(second) - contentView.addSubview(splitView) - splitView.setPosition(1, ofDividerAt: 0) - splitView.adjustSubviews() - contentView.layoutSubtreeIfNeeded() - - let host = WindowTerminalHostView(frame: contentView.bounds) - host.autoresizingMask = [.width, .height] - let child = CapturingView(frame: host.bounds) - child.autoresizingMask = [.width, .height] - host.addSubview(child) - contentView.addSubview(host) - - let dividerPointInSplit = NSPoint( - x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), - y: splitView.bounds.midY - ) - let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) - XCTAssertNil( - host.hitTest(dividerPointInHost), - "Host view must pass through divider hits even when one pane is nearly collapsed" - ) - - let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) - let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) - let contentPointInHost = host.convert(contentPointInWindow, from: nil) - XCTAssertTrue(host.hitTest(contentPointInHost) === child) - } -} - -@MainActor -final class WindowBrowserHostViewTests: XCTestCase { - private final class CapturingView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class PrimaryPageProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class WKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class EdgeTransparentWKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - let localPoint = convert(point, from: superview) - guard bounds.contains(localPoint) else { return nil } - return localPoint.x <= 12 ? nil : self - } - } - - private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - let localPoint = convert(point, from: superview) - guard bounds.contains(localPoint) else { return nil } - return localPoint.x >= bounds.maxX - 12 ? nil : self - } - } - - private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} - - private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create \(type) mouse event") - } - return event - } - - private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool { - guard let hit else { return false } - if hit === pageView || hit.isDescendant(of: pageView) { - return false - } - if hit === inspectorView || hit.isDescendant(of: inspectorView) { - return true - } - return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) - } - - func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let splitView = NSSplitView(frame: contentView.bounds) - splitView.autoresizingMask = [.width, .height] - splitView.isVertical = true - splitView.dividerStyle = .thin - let splitDelegate = BonsplitMockSplitDelegate() - splitView.delegate = splitDelegate - let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) - let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) - splitView.addSubview(first) - splitView.addSubview(second) - contentView.addSubview(splitView) - splitView.setPosition(1, ofDividerAt: 0) - splitView.adjustSubviews() - contentView.layoutSubtreeIfNeeded() - - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - let child = CapturingView(frame: host.bounds) - child.autoresizingMask = [.width, .height] - host.addSubview(child) - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let dividerPointInSplit = NSPoint( - x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), - y: splitView.bounds.midY - ) - let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) - XCTAssertNil( - host.hitTest(dividerPointInHost), - "Browser host must pass through divider hits even when one pane is nearly collapsed" - ) - - let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) - let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) - let contentPointInHost = host.convert(contentPointInWindow, from: nil) - XCTAssertTrue(host.hitTest(contentPointInHost) === child) - } - - func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let appSplit = NSSplitView(frame: contentView.bounds) - appSplit.autoresizingMask = [.width, .height] - appSplit.isVertical = true - appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))) - appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height))) - contentView.addSubview(appSplit) - - let inspectorSplit = NSSplitView(frame: host.bounds) - inspectorSplit.autoresizingMask = [.width, .height] - inspectorSplit.isVertical = true - inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))) - inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height))) - host.addSubview(inspectorSplit) - - XCTAssertTrue( - WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( - appSplit, - window: window, - hostView: host - ), - "App layout splits should still trigger browser portal geometry sync" - ) - XCTAssertFalse( - WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( - inspectorSplit, - window: window, - hostView: host - ), - "Hosted DevTools/internal splits should not trigger browser portal geometry sync" - ) - } - - func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { - XCTAssertTrue( - WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .cursorUpdate - ) - ) - XCTAssertTrue( - WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .mouseEntered - ) - ) - } - - func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { - XCTAssertTrue( - WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], - eventType: .cursorUpdate - ) - ) - } - - func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { - XCTAssertFalse( - WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [.fileURL], - eventType: .cursorUpdate - ) - ) - } - - func testHostViewKeepsHostedInspectorDividerInteractive() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - // Underlying app layout split that should still be pass-through. - let appSplit = NSSplitView(frame: contentView.bounds) - appSplit.autoresizingMask = [.width, .height] - appSplit.isVertical = true - appSplit.dividerStyle = .thin - let appSplitDelegate = BonsplitMockSplitDelegate() - appSplit.delegate = appSplitDelegate - let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) - let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) - appSplit.addSubview(leading) - appSplit.addSubview(trailing) - contentView.addSubview(appSplit) - appSplit.adjustSubviews() - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - // WebKit inspector uses an internal split (page + console). Divider drags - // here must stay in hosted content, not pass through to appSplit behind it. - let inspectorSplit = NSSplitView(frame: host.bounds) - inspectorSplit.autoresizingMask = [.width, .height] - inspectorSplit.isVertical = false - inspectorSplit.dividerStyle = .thin - let inspectorDelegate = BonsplitMockSplitDelegate() - inspectorSplit.delegate = inspectorDelegate - let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) - let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) - inspectorSplit.addSubview(pageView) - inspectorSplit.addSubview(consoleView) - host.addSubview(inspectorSplit) - inspectorSplit.setPosition(160, ofDividerAt: 0) - inspectorSplit.adjustSubviews() - contentView.layoutSubtreeIfNeeded() - - let appDividerPointInSplit = NSPoint( - x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5), - y: appSplit.bounds.midY - ) - let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil) - let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil) - XCTAssertNil( - host.hitTest(appDividerPointInHost), - "Underlying app split divider should still pass through with a hosted inspector split present" - ) - - let dividerPointInInspector = NSPoint( - x: inspectorSplit.bounds.midX, - y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5) - ) - let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - let hit = host.hitTest(dividerPointInHost) - - XCTAssertNotNil( - hit, - "Inspector divider should receive hit-testing in hosted content, not pass through" - ) - XCTAssertFalse(hit === host) - if let hit { - XCTAssertTrue( - hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), - "Expected hit to remain inside inspector split subtree" - ) - } - } - - func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let inspectorSplit = NSSplitView(frame: slot.bounds) - inspectorSplit.autoresizingMask = [.width, .height] - inspectorSplit.isVertical = true - inspectorSplit.dividerStyle = .thin - let inspectorDelegate = BonsplitMockSplitDelegate() - inspectorSplit.delegate = inspectorDelegate - let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) - let inspectorView = CapturingView( - frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height) - ) - inspectorSplit.addSubview(pageView) - inspectorSplit.addSubview(inspectorView) - slot.addSubview(inspectorSplit) - inspectorSplit.setPosition(1, ofDividerAt: 0) - inspectorSplit.adjustSubviews() - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSplit = NSPoint( - x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5), - y: inspectorSplit.bounds.midY - ) - let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - - XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) - XCTAssertTrue( - abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, - "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" - ) - - let hit = host.hitTest(dividerPointInHost) - XCTAssertNotNil( - hit, - "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge" - ) - XCTAssertFalse(hit === host) - if let hit { - XCTAssertTrue( - hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), - "Expected hit to remain inside hosted inspector split subtree at the slot edge" - ) - } - } - - func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) - let inspectorView = WKInspectorProbeView( - frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) - ) - slot.addSubview(pageView) - slot.addSubview(inspectorView) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY) - let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) - let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) - - let dividerHit = host.hitTest(dividerPointInHost) - XCTAssertTrue( - isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), - "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))" - ) - let interiorHit = host.hitTest(bodyPointInHost) - XCTAssertTrue( - isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), - "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" - ) - } - - func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let wrapper = NSView(frame: slot.bounds) - wrapper.autoresizingMask = [.width, .height] - slot.addSubview(wrapper) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)) - let inspectorContainer = NSView( - frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height) - ) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - wrapper.addSubview(pageView) - wrapper.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY) - let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) - let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) - - let dividerHit = host.hitTest(dividerPointInHost) - XCTAssertTrue( - isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), - "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))" - ) - let interiorHit = host.hitTest(bodyPointInHost) - XCTAssertTrue( - isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), - "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" - ) - } - - func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let wrapper = NSView(frame: slot.bounds) - wrapper.autoresizingMask = [.width, .height] - slot.addSubview(wrapper) - - let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height) - let originalInspectorFrame = NSRect( - x: 92, - y: 0, - width: wrapper.bounds.width - 92, - height: wrapper.bounds.height - ) - let pageView = PrimaryPageProbeView(frame: originalPageFrame) - let inspectorContainer = NSView(frame: originalInspectorFrame) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - wrapper.addSubview(pageView) - wrapper.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - let draggedPageWidth = pageView.frame.width - let draggedInspectorMinX = inspectorContainer.frame.minX - XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) - XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) - - pageView.frame = originalPageFrame - inspectorContainer.frame = originalInspectorFrame - slot.needsLayout = true - slot.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) - XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) - } - - func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) - let inspectorView = EdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) - ) - slot.addSubview(pageView) - slot.addSubview(inspectorView) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - - let dividerHit = host.hitTest(dividerPointInHost) - XCTAssertTrue( - dividerHit === host, - "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))" - ) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan(pageView.frame.width, 92) - XCTAssertGreaterThan(inspectorView.frame.minX, 92) - } - - func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let inspectorView = TrailingEdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height) - ) - let pageView = PrimaryPageProbeView( - frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) - ) - slot.addSubview(inspectorView) - slot.addSubview(pageView) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - - XCTAssertTrue( - host.hitTest(dividerPointInHost) === host, - "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable" - ) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan(inspectorView.frame.width, 92) - XCTAssertGreaterThan(pageView.frame.minX, 92) - } - - func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - guard let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let hostFrame = container.convert(contentView.bounds, from: contentView) - let host = WindowBrowserHostView(frame: hostFrame) - host.autoresizingMask = [.width, .height] - container.addSubview(host, positioned: .above, relativeTo: contentView) - - let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) - slot.autoresizingMask = [.minXMargin, .height] - host.addSubview(slot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height)) - let inspectorView = WKInspectorProbeView(frame: slot.bounds) - slot.addSubview(pageView) - slot.addSubview(inspectorView) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) - let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) - let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) - - XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) - let dividerHit = host.hitTest(dividerPointInHost) - XCTAssertTrue( - isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), - "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))" - ) - } -} - -@MainActor -final class BrowserPanelHostContainerViewTests: XCTestCase { - private final class PrimaryPageProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class TrackingInspectorFrontendWebView: WKWebView { - private(set) var evaluatedJavaScript: [String] = [] - - @MainActor override func evaluateJavaScript( - _ javaScriptString: String, - completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil - ) { - evaluatedJavaScript.append(javaScriptString) - completionHandler?(nil, nil) - } - } - - private final class WKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class EdgeTransparentWKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - let localPoint = convert(point, from: superview) - guard bounds.contains(localPoint) else { return nil } - return localPoint.x <= 12 ? nil : self - } - } - - private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - let localPoint = convert(point, from: superview) - guard bounds.contains(localPoint) else { return nil } - return localPoint.x >= bounds.maxX - 12 ? nil : self - } - } - - private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create \(type) mouse event") - } - return event - } - - func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) - let inspectorContainer = NSView( - frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) - ) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) - let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY) - let interiorHit = host.hitTest(bodyPointInHost) - - XCTAssertTrue( - host.hitTest(dividerPointInHost) === host, - "Browser panel host should claim the right-docked divider edge for the manual resize path" - ) - XCTAssertTrue( - interiorHit == nil || interiorHit !== host, - "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))" - ) - } - - func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height)) - let inspectorContainer = NSView(frame: webViewRoot.bounds) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - XCTAssertTrue( - host.hitTest(dividerPointInHost) === host, - "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap" - ) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan(pageView.frame.width, 0) - XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) - } - - func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) - let inspectorContainer = EdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) - ) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - XCTAssertTrue( - host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, - "The custom DevTools divider should remain draggable at the top edge of the browser pane" - ) - XCTAssertTrue( - host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, - "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" - ) - } - - func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) - let inspectorContainer = EdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) - ) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - XCTAssertTrue( - host.hitTest(dividerPointInHost) === host, - "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable" - ) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan(pageView.frame.width, 92) - XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) - } - - func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) - let inspectorContainer = EdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) - ) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThanOrEqual( - inspectorContainer.frame.width, - 120, - "Shrinking the DevTools pane should clamp to a recoverable minimum width" - ) - XCTAssertTrue( - host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, - "After clamping, the DevTools divider should still be draggable near the top edge" - ) - XCTAssertTrue( - host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, - "After clamping, the DevTools divider should still be draggable near the bottom edge" - ) - } - - func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let slotView = host.ensureLocalInlineSlotView() - let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) - let inspectorView = WKWebView( - frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) - ) - slotView.addSubview(pageView) - slotView.addSubview(inspectorView) - host.pinHostedWebView(pageView, in: slotView) - host.setHostedInspectorFrontendWebView(inspectorView) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue( - host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), - "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" - ) - XCTAssertTrue( - pageView.superview === inspectorView.superview && pageView.superview !== slotView, - "Promotion should move both hosted inspector siblings into the managed side-dock container" - ) - XCTAssertEqual( - pageView.frame.height, - host.bounds.height, - accuracy: 0.5, - "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" - ) - XCTAssertEqual( - inspectorView.frame.height, - host.bounds.height, - accuracy: 0.5, - "Promotion should normalize the inspector height to the host height" - ) - } - - func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let slotView = host.ensureLocalInlineSlotView() - let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) - let inspectorView = WKWebView( - frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) - ) - slotView.addSubview(pageView) - slotView.addSubview(inspectorView) - host.pinHostedWebView(pageView, in: slotView) - host.setHostedInspectorFrontendWebView(inspectorView) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue( - host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), - "The managed side-dock path should be active before drag assertions run" - ) - - let initialPageWidth = pageView.frame.width - let initialInspectorWidth = inspectorView.frame.width - let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan( - inspectorView.frame.width, - initialInspectorWidth, - "Right-docked DevTools should expand when the divider is dragged left" - ) - XCTAssertLessThan( - pageView.frame.width, - initialPageWidth, - "Expanding right-docked DevTools should shrink the page width" - ) - } - - func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let slotView = host.ensureLocalInlineSlotView() - let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) - let inspectorView = WKWebView( - frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) - ) - slotView.addSubview(pageView) - slotView.addSubview(inspectorView) - host.pinHostedWebView(pageView, in: slotView) - host.setHostedInspectorFrontendWebView(inspectorView) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) - - host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) - host.setFrameSize(NSSize(width: 210, height: host.frame.height)) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertGreaterThanOrEqual( - inspectorView.frame.width, - 120, - "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" - ) - XCTAssertEqual( - inspectorView.frame.height, - host.bounds.height, - accuracy: 0.5, - "Automatic shrink should keep the inspector vertically normalized to the host height" - ) - } - - func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let slotView = host.ensureLocalInlineSlotView() - let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) - let inspectorView = TrackingInspectorFrontendWebView( - frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) - ) - slotView.addSubview(pageView) - slotView.addSubview(inspectorView) - host.pinHostedWebView(pageView, in: slotView) - host.setHostedInspectorFrontendWebView(inspectorView) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) - - host.setFrameSize(NSSize(width: 210, height: host.frame.height)) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue( - inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), - "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" - ) - XCTAssertTrue( - inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }), - "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls" - ) - } - - func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let slotView = host.ensureLocalInlineSlotView() - let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) - let inspectorView = WKWebView( - frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) - ) - slotView.addSubview(pageView) - slotView.addSubview(inspectorView) - host.pinHostedWebView(pageView, in: slotView) - host.setHostedInspectorFrontendWebView(inspectorView) - contentView.layoutSubtreeIfNeeded() - host.layoutSubtreeIfNeeded() - - XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) - - let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - guard let managedContainer = pageView.superview else { - XCTFail("Expected managed side-dock container") - return - } - let draggedPageFrame = pageView.frame - let draggedInspectorFrame = inspectorView.frame - - managedContainer.setFrameSize( - NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) - ) - - XCTAssertEqual( - pageView.frame.origin.x, - draggedPageFrame.origin.x, - accuracy: 0.5, - "Managed side-dock container should not autoresize the page back to a stale divider position" - ) - XCTAssertEqual( - pageView.frame.width, - draggedPageFrame.width, - accuracy: 0.5, - "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" - ) - XCTAssertEqual( - inspectorView.frame.origin.x, - draggedInspectorFrame.origin.x, - accuracy: 0.5, - "Managed side-dock container should preserve the dragged inspector origin" - ) - XCTAssertEqual( - inspectorView.frame.width, - draggedInspectorFrame.width, - accuracy: 0.5, - "Managed side-dock container should preserve the dragged inspector width" - ) - } - - func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView( - frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) - ) - let pageView = PrimaryPageProbeView( - frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) - ) - webViewRoot.addSubview(inspectorContainer) - webViewRoot.addSubview(pageView) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - XCTAssertTrue( - host.hitTest(dividerPointInHost) === host, - "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable" - ) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - XCTAssertGreaterThan(inspectorContainer.frame.width, 92) - XCTAssertGreaterThan(pageView.frame.minX, 92) - } - - func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let host = WebViewRepresentable.HostContainerView( - frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height) - ) - host.autoresizingMask = [.minXMargin, .height] - contentView.addSubview(host) - - let webViewRoot = NSView(frame: host.bounds) - webViewRoot.autoresizingMask = [.width, .height] - host.addSubview(webViewRoot) - - let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) - let originalInspectorFrame = NSRect( - x: 92, - y: 0, - width: webViewRoot.bounds.width - 92, - height: webViewRoot.bounds.height - ) - let pageView = PrimaryPageProbeView(frame: originalPageFrame) - let inspectorContainer = NSView(frame: originalInspectorFrame) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - webViewRoot.addSubview(pageView) - webViewRoot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) - let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) - - let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) - host.mouseDown(with: down) - let drag = makeMouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), - window: window - ) - host.mouseDragged(with: drag) - host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) - - let draggedPageWidth = pageView.frame.width - let draggedInspectorMinX = inspectorContainer.frame.minX - XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) - XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) - - pageView.frame = originalPageFrame - inspectorContainer.frame = originalInspectorFrame - host.needsLayout = true - host.layoutSubtreeIfNeeded() - - XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) - XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) - } - - func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180)) - let webView = WKWebView(frame: .zero) - slot.addSubview(webView) - - slot.pinHostedWebView(webView) - slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220) - slot.layoutSubtreeIfNeeded() - - XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints) - XCTAssertEqual(webView.autoresizingMask, [.width, .height]) - XCTAssertEqual(webView.frame, slot.bounds) - } - - func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180)) - let webView = WKWebView(frame: .zero) - slot.addSubview(webView) - slot.pinHostedWebView(webView) - XCTAssertEqual(webView.frame, slot.bounds) - - let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180)) - webView.removeFromSuperview() - externalHost.addSubview(webView) - webView.frame = externalHost.bounds - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - - slot.addSubview(webView) - slot.pinHostedWebView(webView) - - slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180) - slot.layoutSubtreeIfNeeded() - - XCTAssertEqual( - webView.frame, - slot.bounds, - "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host" - ) - } -} - -@MainActor -final class CmuxWebViewDragRoutingTests: XCTestCase { - func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { - XCTAssertTrue( - CmuxWebView.shouldRejectInternalPaneDrag([ - DragOverlayRoutingPolicy.bonsplitTabTransferType, - NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), - ]) - ) - } - - func testAllowsRegularExternalFileDrops() { - XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) - } -} - -#if compiler(>=6.2) -@available(macOS 26.0, *) -private struct DragConfigurationOperationsSnapshot: Equatable { - let allowCopy: Bool - let allowMove: Bool - let allowDelete: Bool - let allowAlias: Bool -} - -@available(macOS 26.0, *) -private enum DragConfigurationSnapshotError: Error { - case missingBoolField(primary: String, fallback: String?) -} - -@available(macOS 26.0, *) -private func dragConfigurationOperationsSnapshot(from operations: T) throws -> DragConfigurationOperationsSnapshot { - let mirror = Mirror(reflecting: operations) - - func readBool(_ primary: String, fallback: String? = nil) throws -> Bool { - if let value = mirror.descendant(primary) as? Bool { - return value - } - if let fallback, let value = mirror.descendant(fallback) as? Bool { - return value - } - throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback) - } - - return try DragConfigurationOperationsSnapshot( - allowCopy: readBool("allowCopy", fallback: "_allowCopy"), - allowMove: readBool("allowMove", fallback: "_allowMove"), - allowDelete: readBool("allowDelete", fallback: "_allowDelete"), - allowAlias: readBool("allowAlias", fallback: "_allowAlias") - ) -} - -@MainActor -final class InternalTabDragConfigurationTests: XCTestCase { - func testDisablesExternalOperationsForInternalTabDrags() throws { - guard #available(macOS 26.0, *) else { - throw XCTSkip("Requires macOS 26 drag configuration APIs") - } - - let configuration = InternalTabDragConfigurationProvider.value - let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp) - let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp) - - XCTAssertEqual( - withinApp, - DragConfigurationOperationsSnapshot( - allowCopy: false, - allowMove: true, - allowDelete: false, - allowAlias: false - ) - ) - - XCTAssertEqual( - outsideApp, - DragConfigurationOperationsSnapshot( - allowCopy: false, - allowMove: false, - allowDelete: false, - allowAlias: false - ) - ) - } -} - -@MainActor -final class InternalTabDragBundleDeclarationTests: XCTestCase { - private func exportedTypeIdentifiers(bundle: Bundle) -> Set { - let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? [] - return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String }) - } - - func testAppBundleExportsInternalDragTypes() { - let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self)) - - XCTAssertTrue( - exported.contains("com.splittabbar.tabtransfer"), - "Expected app bundle to export bonsplit tab-transfer type, got \(exported)" - ) - XCTAssertTrue( - exported.contains("com.cmux.sidebar-tab-reorder"), - "Expected app bundle to export sidebar tab-reorder type, got \(exported)" - ) - } -} -#endif - -@MainActor -final class BrowserPaneDropRoutingTests: XCTestCase { - func testVerticalZonesFollowAppKitCoordinates() { - let size = CGSize(width: 240, height: 180) - - XCTAssertEqual( - BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), - .top - ) - XCTAssertEqual( - BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), - .bottom - ) - } - - func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { - let size = CGSize(width: 240, height: 180) - - XCTAssertEqual( - BrowserPaneDropRouting.zone( - for: CGPoint(x: size.width * 0.5, y: 110), - in: size, - topChromeHeight: 36 - ), - .center - ) - XCTAssertEqual( - BrowserPaneDropRouting.zone( - for: CGPoint(x: size.width * 0.5, y: 150), - in: size, - topChromeHeight: 36 - ), - .top - ) - } - - func testHitTestingCapturesOnlyForRelevantDragEvents() { - XCTAssertTrue( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .cursorUpdate - ) - ) - XCTAssertFalse( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .leftMouseDown - ) - ) - XCTAssertFalse( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [.fileURL], - eventType: .cursorUpdate - ) - ) - } - - func testCenterDropOnSamePaneIsNoOp() { - let paneId = PaneID(id: UUID()) - let target = BrowserPaneDropContext( - workspaceId: UUID(), - panelId: UUID(), - paneId: paneId - ) - let transfer = BrowserPaneDragTransfer( - tabId: UUID(), - sourcePaneId: paneId.id, - sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) - ) - - XCTAssertEqual( - BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), - .noOp - ) - } - - func testRightEdgeDropBuildsSplitMoveAction() { - let paneId = PaneID(id: UUID()) - let target = BrowserPaneDropContext( - workspaceId: UUID(), - panelId: UUID(), - paneId: paneId - ) - let tabId = UUID() - let transfer = BrowserPaneDragTransfer( - tabId: tabId, - sourcePaneId: UUID(), - sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) - ) - - XCTAssertEqual( - BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), - .move( - tabId: tabId, - targetWorkspaceId: target.workspaceId, - targetPane: paneId, - splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) - ) - ) - } - - func testDecodeTransferPayloadReadsTabAndSourcePane() { - let tabId = UUID() - let sourcePaneId = UUID() - let payload = try! JSONSerialization.data( - withJSONObject: [ - "tab": ["id": tabId.uuidString], - "sourcePaneId": sourcePaneId.uuidString, - "sourceProcessId": ProcessInfo.processInfo.processIdentifier, - ] - ) - - let transfer = BrowserPaneDragTransfer.decode(from: payload) - - XCTAssertEqual(transfer?.tabId, tabId) - XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) - XCTAssertTrue(transfer?.isFromCurrentProcess == true) - } -} - -@MainActor -final class WindowBrowserSlotViewTests: XCTestCase { - private final class CapturingView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private func advanceAnimations() { - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) - } - - func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) - let slot = WindowBrowserSlotView(frame: container.bounds) - container.addSubview(slot) - let child = CapturingView(frame: slot.bounds) - child.autoresizingMask = [.width, .height] - slot.addSubview(child) - - slot.setDropZoneOverlay(zone: .right) - container.layoutSubtreeIfNeeded() - - guard let overlay = container.subviews.first(where: { - $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") - }) else { - XCTFail("Expected browser slot drop-zone overlay") - return - } - - XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") - XCTAssertFalse(overlay.isHidden) - XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) - XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5) - XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits") - XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child) - - slot.setDropZoneOverlay(zone: nil) - advanceAnimations() - XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") - } - - func testTopDropZoneOverlayUsesFullBrowserContentHeight() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) - let slot = WindowBrowserSlotView(frame: container.bounds) - container.addSubview(slot) - - slot.setPaneTopChromeHeight(20) - slot.setDropZoneOverlay(zone: .top) - container.layoutSubtreeIfNeeded() - - guard let overlay = container.subviews.first(where: { - String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") - }) else { - XCTFail("Expected browser slot drop-zone overlay") - return - } - - XCTAssertFalse(overlay.isHidden) - XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) - XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) - XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) - XCTAssertEqual(slot.layer?.masksToBounds, true) - - slot.setDropZoneOverlay(zone: nil) - advanceAnimations() - XCTAssertEqual(slot.layer?.masksToBounds, true) - } -} - -@MainActor -final class WindowDragHandleHitTests: XCTestCase { - private final class CapturingView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - - private final class HostContainerView: NSView {} - private final class BlockingTopHitContainerView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - bounds.contains(point) ? self : nil - } - } - private final class PassThroughProbeView: NSView { - var onHitTest: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - onHitTest?() - return nil - } - } - private final class PassiveHostContainerView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - return super.hitTest(point) ?? self - } - } - - private final class MutatingSiblingView: NSView { - weak var container: NSView? - private var didMutate = false - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - guard !didMutate, let container else { return nil } - didMutate = true - let transient = NSView(frame: .zero) - container.addSubview(transient) - transient.removeFromSuperview() - return nil - } - } - - private final class ReentrantDragHandleView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { - let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window) - return shouldCapture ? self : nil - } - } - - /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit, - /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout - /// pass that calls back into the drag handle's hit resolution. - private final class ReentrantSiblingView: NSView { - weak var dragHandle: NSView? - var reenteredResult: Bool? - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point), let dragHandle else { return nil } - // Simulate the re-entry: during sibling hit test, SwiftUI layout - // calls windowDragHandleShouldCaptureHit on the drag handle again. - reenteredResult = windowDragHandleShouldCaptureHit( - point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window - ) - return nil - } - } - - func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - XCTAssertTrue( - windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), - "Empty titlebar space should drag the window" - ) - } - - func testDragHandleYieldsWhenSiblingClaimsPoint() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) - container.addSubview(folderIconHost) - - XCTAssertFalse( - windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), - "Interactive titlebar controls should receive the mouse event" - ) - XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown)) - } - - func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) - hidden.isHidden = true - container.addSubview(hidden) - - XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown)) - } - - func testDragHandleDoesNotCaptureOutsideBounds() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown)) - } - - func testDragHandleSkipsCaptureForPassivePointerEvents() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let point = NSPoint(x: 180, y: 18) - XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved)) - XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate)) - XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil)) - XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown)) - } - - func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() { - let point = NSPoint(x: 180, y: 18) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let container = NSView(frame: contentView.bounds) - container.autoresizingMask = [.width, .height] - contentView.addSubview(container) - - let dragHandle = NSView(frame: container.bounds) - dragHandle.autoresizingMask = [.width, .height] - container.addSubview(dragHandle) - - let foreignWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { foreignWindow.orderOut(nil) } - - XCTAssertFalse( - windowDragHandleShouldCaptureHit( - point, - in: dragHandle, - eventType: .leftMouseDown, - eventWindow: nil - ), - "Launch activation events without a matching window should not trigger drag-handle hierarchy walk" - ) - - XCTAssertFalse( - windowDragHandleShouldCaptureHit( - point, - in: dragHandle, - eventType: .leftMouseDown, - eventWindow: foreignWindow - ), - "Left mouse-down events for a different window should be treated as passive" - ) - - XCTAssertTrue( - windowDragHandleShouldCaptureHit( - point, - in: dragHandle, - eventType: .leftMouseDown, - eventWindow: window - ), - "Left mouse-down events for this window should still capture empty titlebar space" - ) - } - - func testPassiveHostingTopHitClassification() { - XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) - XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) - } - - func testDragHandleIgnoresPassiveHostSiblingHit() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let passiveHost = PassiveHostContainerView(frame: container.bounds) - container.addSubview(passiveHost) - - XCTAssertTrue( - windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), - "Passive host wrappers should not block titlebar drag capture" - ) - } - - func testDragHandleRespectsInteractiveChildInsidePassiveHost() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let passiveHost = PassiveHostContainerView(frame: container.bounds) - let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) - passiveHost.addSubview(folderControl) - container.addSubview(passiveHost) - - XCTAssertFalse( - windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), - "Interactive controls inside passive host wrappers should still receive hits" - ) - } - - func testTopHitResolutionStateIsScopedPerWindow() { - let point = NSPoint(x: 100, y: 18) - - let outerWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { outerWindow.orderOut(nil) } - guard let outerContentView = outerWindow.contentView else { - XCTFail("Expected outer content view") - return - } - let outerContainer = NSView(frame: outerContentView.bounds) - outerContainer.autoresizingMask = [.width, .height] - outerContentView.addSubview(outerContainer) - let outerDragHandle = NSView(frame: outerContainer.bounds) - outerDragHandle.autoresizingMask = [.width, .height] - outerContainer.addSubview(outerDragHandle) - - let nestedWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { nestedWindow.orderOut(nil) } - guard let nestedContentView = nestedWindow.contentView else { - XCTFail("Expected nested content view") - return - } - let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds) - nestedContainer.autoresizingMask = [.width, .height] - nestedContentView.addSubview(nestedContainer) - let nestedDragHandle = NSView(frame: nestedContainer.bounds) - nestedDragHandle.autoresizingMask = [.width, .height] - nestedContainer.addSubview(nestedDragHandle) - - XCTAssertFalse( - windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow), - "Nested window drag handle should be blocked by top-hit titlebar container" - ) - - var nestedCaptureResult: Bool? - let probe = PassThroughProbeView(frame: outerContainer.bounds) - probe.autoresizingMask = [.width, .height] - probe.onHitTest = { - nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow) - } - outerContainer.addSubview(probe) - - _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow) - - XCTAssertEqual( - nestedCaptureResult, - false, - "Top-hit recursion in one window must not disable top-hit resolution in another window" - ) - } - - func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let mutatingSibling = MutatingSiblingView(frame: container.bounds) - mutatingSibling.container = container - container.addSubview(mutatingSibling) - - XCTAssertTrue( - windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), - "Subview mutations during hit testing should not crash or break drag-handle capture" - ) - } - - func testDragHandleSiblingHitTestReentrancyDoesNotCrash() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) - let dragHandle = NSView(frame: container.bounds) - container.addSubview(dragHandle) - - let reentrantSibling = ReentrantSiblingView(frame: container.bounds) - reentrantSibling.dragHandle = dragHandle - container.addSubview(reentrantSibling) - - // The outer call enters the sibling walk, which calls - // reentrantSibling.hitTest(), which re-enters - // windowDragHandleShouldCaptureHit. Without the re-entrancy guard - // this would trigger a Swift exclusive-access violation (SIGABRT). - let outerResult = windowDragHandleShouldCaptureHit( - NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown - ) - XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil") - XCTAssertEqual( - reentrantSibling.reenteredResult, false, - "Re-entrant call should bail out (return false) instead of crashing" - ) - } - - func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() { - let point = NSPoint(x: 180, y: 18) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let container = NSView(frame: contentView.bounds) - container.autoresizingMask = [.width, .height] - contentView.addSubview(container) - - let dragHandle = ReentrantDragHandleView(frame: container.bounds) - dragHandle.autoresizingMask = [.width, .height] - container.addSubview(dragHandle) - - XCTAssertTrue( - windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window), - "Reentrant same-window top-hit resolution should not trigger exclusivity crashes" - ) - } -} - -#if DEBUG -@MainActor -final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { - override func setUp() { - super.setUp() - SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() - } - - override func tearDown() { - SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() - super.tearDown() - } - - func testHintWidthCachesRepeatedMeasurements() { - XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0) - - let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") - XCTAssertGreaterThan(first, 0) - XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) - - let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") - XCTAssertEqual(second, first) - XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) - - _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2") - XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2) - } - - func testSlotWidthAppliesMinimumAndDebugInset() { - let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999) - XCTAssertEqual(nilLabelWidth, 28) - - let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0) - let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10) - XCTAssertGreaterThan(widened, base) - } -} -#endif - -@MainActor -final class DraggableFolderHitTests: XCTestCase { - func testFolderHitTestReturnsContainerWhenInsideBounds() { - let folderView = DraggableFolderNSView(directory: "/tmp") - folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) - - guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { - XCTFail("Expected folder icon to capture inside hit") - return - } - XCTAssertTrue(hit === folderView) - } - - func testFolderHitTestReturnsNilOutsideBounds() { - let folderView = DraggableFolderNSView(directory: "/tmp") - folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) - - XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) - } - - func testFolderIconDisablesWindowMoveBehavior() { - let folderView = DraggableFolderNSView(directory: "/tmp") - XCTAssertFalse(folderView.mouseDownCanMoveWindow) - } -} - -@MainActor -final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { - func testLeadingInsetViewDoesNotParticipateInHitTesting() { - let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) - XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) - } - - func testLeadingInsetViewCannotMoveWindowViaMouseDown() { - let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) - XCTAssertFalse(view.mouseDownCanMoveWindow) - } -} - -@MainActor -final class FolderWindowMoveSuppressionTests: XCTestCase { - private func makeWindow() -> NSWindow { - NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - } - - func testSuppressionDisablesMovableWindow() { - let window = makeWindow() - window.isMovable = true - - let previous = temporarilyDisableWindowDragging(window: window) - - XCTAssertEqual(previous, true) - XCTAssertFalse(window.isMovable) - } - - func testSuppressionPreservesAlreadyImmovableWindow() { - let window = makeWindow() - window.isMovable = false - - let previous = temporarilyDisableWindowDragging(window: window) - - XCTAssertEqual(previous, false) - XCTAssertFalse(window.isMovable) - } - - func testRestoreAppliesPreviousMovableState() { - let window = makeWindow() - window.isMovable = false - - restoreWindowDragging(window: window, previousMovableState: true) - XCTAssertTrue(window.isMovable) - - restoreWindowDragging(window: window, previousMovableState: false) - XCTAssertFalse(window.isMovable) - } - - func testWindowDragSuppressionDepthLifecycle() { - let window = makeWindow() - XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) - XCTAssertFalse(isWindowDragSuppressed(window: window)) - - XCTAssertEqual(beginWindowDragSuppression(window: window), 1) - XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) - XCTAssertTrue(isWindowDragSuppressed(window: window)) - - XCTAssertEqual(endWindowDragSuppression(window: window), 0) - XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) - XCTAssertFalse(isWindowDragSuppressed(window: window)) - } - - func testWindowDragSuppressionIsReferenceCounted() { - let window = makeWindow() - XCTAssertEqual(beginWindowDragSuppression(window: window), 1) - XCTAssertEqual(beginWindowDragSuppression(window: window), 2) - XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) - XCTAssertTrue(isWindowDragSuppressed(window: window)) - - XCTAssertEqual(endWindowDragSuppression(window: window), 1) - XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) - XCTAssertTrue(isWindowDragSuppressed(window: window)) - - XCTAssertEqual(endWindowDragSuppression(window: window), 0) - XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) - XCTAssertFalse(isWindowDragSuppressed(window: window)) - } - - func testTemporaryWindowMovableEnableRestoresImmovableWindow() { - let window = makeWindow() - window.isMovable = false - - let previous = withTemporaryWindowMovableEnabled(window: window) { - XCTAssertTrue(window.isMovable) - } - - XCTAssertEqual(previous, false) - XCTAssertFalse(window.isMovable) - } - - func testTemporaryWindowMovableEnablePreservesMovableWindow() { - let window = makeWindow() - window.isMovable = true - - let previous = withTemporaryWindowMovableEnabled(window: window) { - XCTAssertTrue(window.isMovable) - } - - XCTAssertEqual(previous, true) - XCTAssertTrue(window.isMovable) - } -} - -@MainActor -final class WindowMoveSuppressionHitPathTests: XCTestCase { - private func makeWindowWithContentView() -> (NSWindow, NSView) { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = contentView - return (window, contentView) - } - - private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Failed to create \(type) mouse event") - } - return event - } - - func testSuppressionHitPathRecognizesFolderView() { - let folderView = DraggableFolderNSView(directory: "/tmp") - XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) - } - - func testSuppressionHitPathRecognizesDescendantOfFolderView() { - let folderView = DraggableFolderNSView(directory: "/tmp") - let child = NSView(frame: .zero) - folderView.addSubview(child) - XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) - } - - func testSuppressionHitPathIgnoresUnrelatedViews() { - XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) - XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) - } - - func testSuppressionEventPathRecognizesFolderHitInsideWindow() { - let (window, contentView) = makeWindowWithContentView() - window.isMovable = true - let folderView = DraggableFolderNSView(directory: "/tmp") - folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) - contentView.addSubview(folderView) - - let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) - - XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) - } - - func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { - let (window, contentView) = makeWindowWithContentView() - window.isMovable = true - let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) - contentView.addSubview(plainView) - - let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) - XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) - - let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) - XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) - } -} - -@MainActor -final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { - func testShouldPromoteWhenBecomingVisible() { - XCTAssertTrue( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: false, - isVisible: true - ) - ) - } - - func testShouldNotPromoteWhenAlreadyVisible() { - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: true, - isVisible: true - ) - ) - } - - func testShouldNotPromoteWhenHidden() { - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: true, - isVisible: false - ) - ) - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: false, - isVisible: false - ) - ) - } -} - -@MainActor -final class GhosttySurfaceOverlayTests: XCTestCase { - private final class ScrollProbeSurfaceView: GhosttyNSView { - private(set) var scrollWheelCallCount = 0 - - override func scrollWheel(with event: NSEvent) { - scrollWheelCallCount += 1 - } - } - - private func findEditableTextField(in view: NSView) -> NSTextField? { - if let field = view as? NSTextField, field.isEditable { - return field - } - for subview in view.subviews { - if let field = findEditableTextField(in: subview) { - return field - } - } - return nil - } - - private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { - if firstResponder === textField { - return true - } - if let editor = firstResponder as? NSTextView, - editor.isFieldEditor, - editor.delegate as? NSTextField === textField { - return true - } - return false - } - - func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) - let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { - XCTFail("Expected hosted terminal scroll view") - return - } - XCTAssertFalse( - scrollView.acceptsFirstResponder, - "Host scroll view should not become first responder and steal terminal shortcuts" - ) - - _ = window.makeFirstResponder(nil) - - guard let cgEvent = CGEvent( - scrollWheelEvent2Source: nil, - units: .pixel, - wheelCount: 2, - wheel1: 0, - wheel2: -12, - wheel3: 0 - ), let scrollEvent = NSEvent(cgEvent: cgEvent) else { - XCTFail("Expected scroll wheel event") - return - } - - scrollView.scrollWheel(with: scrollEvent) - - XCTAssertEqual( - surfaceView.scrollWheelCallCount, - 1, - "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling" - ) - XCTAssertTrue( - window.firstResponder === surfaceView, - "Scroll wheel handling should keep keyboard focus on terminal surface" - ) - } - - func testInactiveOverlayVisibilityTracksRequestedState() { - let hostedView = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) - ) - - hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true) - var state = hostedView.debugInactiveOverlayState() - XCTAssertFalse(state.isHidden) - XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01) - - hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false) - state = hostedView.debugInactiveOverlayState() - XCTAssertTrue(state.isHidden) - } - - func testWindowResignKeyClearsFocusedTerminalFirstResponder() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let hostedView = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) - ) - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - hostedView.setVisibleInUI(true) - hostedView.setActive(true) - hostedView.moveFocus() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue( - hostedView.isSurfaceViewFirstResponder(), - "Expected terminal surface to be first responder before window blur" - ) - - NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertFalse( - hostedView.isSurfaceViewFirstResponder(), - "Window blur should force terminal surface to resign first responder" - ) - } - - func testSearchOverlayMountsAndUnmountsWithSearchState() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - XCTAssertFalse(hostedView.debugHasSearchOverlay()) - - let searchState = TerminalSurface.SearchState(needle: "example") - hostedView.setSearchOverlay(searchState: searchState) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - hostedView.setSearchOverlay(searchState: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertFalse(hostedView.debugHasSearchOverlay()) - } - - func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - - hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) - hostedView.setSearchOverlay(searchState: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertFalse( - hostedView.debugHasSearchOverlay(), - "A stale deferred mount must not resurrect the find overlay after it closes" - ) - } - - func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - hostedView.setVisibleInUI(true) - hostedView.setActive(true) - - let searchState = TerminalSurface.SearchState(needle: "") - surface.searchState = searchState - hostedView.setSearchOverlay(searchState: searchState) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let searchField = findEditableTextField(in: hostedView) else { - XCTFail("Expected mounted find text field") - return - } - - XCTAssertTrue( - firstResponderOwnsTextField(window.firstResponder, textField: searchField), - "Deferred search overlay attach should still move focus into the find field" - ) - } - - func testStartOrFocusTerminalSearchReusesExistingSearchState() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let existingSearchState = TerminalSurface.SearchState(needle: "existing") - surface.searchState = existingSearchState - - var focusNotificationCount = 0 - XCTAssertTrue( - startOrFocusTerminalSearch(surface) { _ in - focusNotificationCount += 1 - } - ) - - XCTAssertTrue(surface.searchState === existingSearchState) - XCTAssertEqual( - focusNotificationCount, - 1, - "Re-triggering terminal Find should refocus the existing overlay without recreating state" - ) - } - - func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { - _ = NSApplication.shared - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil - window.orderOut(nil) - } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - hostedView.setVisibleInUI(true) - hostedView.setActive(true) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - let searchState = TerminalSurface.SearchState(needle: "") - surface.searchState = searchState - hostedView.setSearchOverlay(searchState: searchState) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - guard let searchField = findEditableTextField(in: hostedView) else { - XCTFail("Expected mounted find text field") - return - } - window.makeFirstResponder(searchField) - - var escapeKeyUpCount = 0 - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return } - escapeKeyUpCount += 1 - } - - let timestamp = ProcessInfo.processInfo.systemUptime - guard let escapeKeyDown = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: timestamp, - windowNumber: window.windowNumber, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - ), let escapeKeyUp = NSEvent.keyEvent( - with: .keyUp, - location: .zero, - modifierFlags: [], - timestamp: timestamp + 0.001, - windowNumber: window.windowNumber, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - ) else { - XCTFail("Failed to construct Escape key events") - return - } - - NSApp.sendEvent(escapeKeyDown) - NSApp.sendEvent(escapeKeyUp) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty") - XCTAssertEqual( - escapeKeyUpCount, - 0, - "Escape used to dismiss find overlay must not pass through to the terminal key-up path" - ) - } - - @MainActor - func testKeyboardCopyModeIndicatorMountsAndUnmounts() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) - - hostedView.syncKeyStateIndicator(text: "vim") - XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) - - hostedView.syncKeyStateIndicator(text: nil) - XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) - } - - @MainActor - func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() { - let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) - let surfaceView = GhosttyNSView(frame: .zero) - let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) - hostedView.frame = container.bounds - container.addSubview(hostedView) - - hostedView.setDropZoneOverlay(zone: .right) - container.layoutSubtreeIfNeeded() - - let state = hostedView.debugDropZoneOverlayState() - XCTAssertFalse(state.isHidden) - XCTAssertFalse( - state.isAttachedToHostedView, - "Drop-hover overlay should be mounted outside the hosted terminal view" - ) - XCTAssertTrue( - state.isAttachedToParentContainer, - "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout" - ) - XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5) - XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5) - XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5) - XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5) - - hostedView.setDropZoneOverlay(zone: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) - XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden) - } - - func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { -#if DEBUG - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - hostedView.frame = contentView.bounds - hostedView.autoresizingMask = [.width, .height] - contentView.addSubview(hostedView) - - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - contentView.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - hostedView.reconcileGeometryNow() - surface.releaseSurfaceForTesting() - XCTAssertNil(surface.surface, "Surface should be nil after test release helper") - - hostedView.reconcileGeometryNow() - surface.forceRefresh() - XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil") -#else - throw XCTSkip("Debug-only regression test") -#endif - } - - func testSearchOverlayMountDoesNotRetainTerminalSurface() { - weak var weakSurface: TerminalSurface? - - let hostedView: GhosttySurfaceScrollView = { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - weakSurface = surface - let hostedView = surface.hostedView - hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check")) - return hostedView - }() - - RunLoop.main.run(until: Date().addingTimeInterval(0.01)) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface") - } - - func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - let portal = WindowTerminalPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140)) - let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140)) - contentView.addSubview(anchorA) - contentView.addSubview(anchorB) - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true) - XCTAssertTrue( - hostedView.debugHasSearchOverlay(), - "Split-like anchor churn should not unmount terminal search overlay" - ) - } - - func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - let portal = WindowTerminalPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160)) - contentView.addSubview(anchor) - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) - XCTAssertTrue( - hostedView.debugHasSearchOverlay(), - "Workspace-switch-like visibility toggles should not unmount terminal search overlay" - ) - } -} - -@MainActor -final class TerminalWindowPortalLifecycleTests: XCTestCase { - private final class ContentViewCountingWindow: NSWindow { - var contentViewReadCount = 0 - - override var contentView: NSView? { - get { - contentViewReadCount += 1 - return super.contentView - } - set { - super.contentView = newValue - } - } - } - - private func realizeWindowLayout(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - window.contentView?.layoutSubtreeIfNeeded() - } - - func testPortalHostInstallsAboveContentViewForVisibility() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - _ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), - let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { - XCTFail("Expected host/content views in same container") - return - } - - XCTAssertGreaterThan( - hostIndex, - contentIndex, - "Portal host must remain above content view so portal-hosted terminals stay visible" - ) - } - - func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - - let browserPortal = WindowBrowserPortal(window: window) - let terminalPortal = WindowTerminalPortal(window: window) - _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) - _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - func assertHostOrder(_ message: String) { - guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), - let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else { - XCTFail("Expected both portal hosts in same container") - return - } - - XCTAssertLessThan( - terminalHostIndex, - browserHostIndex, - message - ) - } - - assertHostOrder("Terminal portal host should start below browser portal host") - - let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150)) - contentView.addSubview(anchor) - let hosted = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - ) - terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true) - terminalPortal.synchronizeHostedViewForAnchor(anchor) - - assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host") - } - - func testRegistryPrunesPortalWhenWindowCloses() { - let baseline = TerminalWindowPortalRegistry.debugPortalCount() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - - _ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window) - XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1) - - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline) - } - - func testPruneDeadEntriesDetachesAnchorlessHostedView() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let hosted1 = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) - ) - - var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80)) - contentView.addSubview(anchor1!) - portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true) - - anchor1?.removeFromSuperview() - anchor1 = nil - - let hosted2 = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) - ) - let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80)) - contentView.addSubview(anchor2) - portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) - - XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked") - XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") - } - - func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() { - let window = ContentViewCountingWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120)) - contentView.addSubview(anchor) - let hosted = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80)) - ) - portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) - - let baselineReads = window.contentViewReadCount - for _ in 0..<25 { - portal.synchronizeHostedViewForAnchor(anchor) - } - - XCTAssertEqual( - window.contentViewReadCount, - baselineReads, - "Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView" - ) - } - - func testTerminalViewAtWindowPointResolvesPortalHostedSurface() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120)) - contentView.addSubview(anchor) - - let hosted = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80)) - ) - portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) - - let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) - let windowPoint = anchor.convert(center, to: nil) - XCTAssertNotNil( - portal.terminalViewAtWindowPoint(windowPoint), - "Portal hit-testing should resolve the terminal view for Finder file drops" - ) - } - - func testVisibilityTransitionBringsHostedViewToFront() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) - let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) - contentView.addSubview(anchor1) - contentView.addSubview(anchor2) - - let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) - let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) - - portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) - portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) - - let overlapInContent = NSPoint(x: 120, y: 100) - let overlapInWindow = contentView.convert(overlapInContent, to: nil) - XCTAssertTrue( - portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, - "Latest bind should be top-most before visibility transition" - ) - - portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false) - portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) - XCTAssertTrue( - portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, - "Becoming visible should refresh z-order for already-hosted view" - ) - } - - func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - let portal = WindowTerminalPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) - let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) - contentView.addSubview(anchor1) - contentView.addSubview(anchor2) - - let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) - let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) - - portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1) - portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2) - - let overlapInContent = NSPoint(x: 120, y: 100) - let overlapInWindow = contentView.convert(overlapInContent, to: nil) - XCTAssertTrue( - portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, - "Higher-priority terminal should initially be top-most" - ) - - portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2) - XCTAssertTrue( - portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, - "Promoting z-priority should bring an already-visible terminal to front" - ) - } - - func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - - let portal = WindowTerminalPortal(window: window) - realizeWindowLayout(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) - contentView.addSubview(anchor) - - let hosted = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - ) - portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) - XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") - - // Collapse to a tiny frame first. - anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") - - // Then restore to a non-zero but still too-small frame. It should remain hidden. - anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue( - hosted.isHidden, - "Portal should defer reveal until geometry reaches a usable size" - ) - - // Once the frame is large enough again, reveal should resume. - anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") - } - - func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { - 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) - } - - realizeWindowLayout(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) - contentView.addSubview(shiftedContainer) - let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) - shiftedContainer.addSubview(anchor) - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hosted = surface.hostedView - TerminalWindowPortalRegistry.bind( - hostedView: hosted, - to: anchor, - visibleInUI: true, - expectedSurfaceId: surface.id, - expectedGeneration: surface.portalBindingGeneration() - ) - TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) - - let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) - let originalWindowPoint = anchor.convert(anchorCenter, to: nil) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), - "Initial hit-testing should resolve the portal-hosted terminal at its original window position" - ) - - shiftedContainer.frame.origin.x += 96 - contentView.layoutSubtreeIfNeeded() - window.displayIfNeeded() - - let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) - XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) - XCTAssertNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), - "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" - ) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), - "Before the external geometry sync, hit-testing should still point at the stale portal location" - ) - - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), - "The stale portal position should be cleared after the scheduled external geometry sync" - ) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), - "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" - ) - } - - func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { - 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) - - 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.scheduleExternalGeometrySynchronizeForAllWindows() - DispatchQueue.main.async { - shiftedContainer.frame.origin.x += 72 - contentView.layoutSubtreeIfNeeded() - window.displayIfNeeded() - } - - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) - XCTAssertGreaterThan( - shiftedAnchorFrameInWindow.minX, - originalAnchorFrameInWindow.minX + 1, - "The queued layout shift should move the anchor to the right" - ) - XCTAssertGreaterThan( - shiftedAnchorFrameInWindow.maxX, - originalAnchorFrameInWindow.maxX + 1, - "The shifted anchor should expose a new trailing region outside the stale portal frame" - ) - let retiredStaleWindowPoint = NSPoint( - x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, - y: shiftedAnchorFrameInWindow.midY - ) - let shiftedWindowPoint = NSPoint( - x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, - y: shiftedAnchorFrameInWindow.midY - ) - XCTAssertNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), - "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" - ) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), - "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" - ) - } -} - -@MainActor -final class BrowserWindowPortalLifecycleTests: XCTestCase { - private final class TrackingPortalWebView: WKWebView { - private(set) var displayIfNeededCount = 0 - private(set) var reattachRenderingStateCount = 0 - - override func displayIfNeeded() { - displayIfNeededCount += 1 - super.displayIfNeeded() - } - - @objc(_enterInWindow) - func cmuxUnitTestEnterInWindow() { - reattachRenderingStateCount += 1 - } - - @objc(_endDeferringViewInWindowChangesSync) - func cmuxUnitTestEndDeferringViewInWindowChangesSync() { - reattachRenderingStateCount += 1 - } - } - - private final class WKInspectorProbeView: NSView {} - - private func realizeWindowLayout(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - window.contentView?.layoutSubtreeIfNeeded() - } - - private func advanceAnimations() { - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) - } - - private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { - let candidates = slot.subviews + (slot.superview?.subviews ?? []) - return candidates.first(where: { - $0 !== slot && - $0 !== webView && - String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") - }) - } - - func testPortalHostInstallsAboveContentViewForVisibility() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - let portal = WindowBrowserPortal(window: window) - _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), - let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { - XCTFail("Expected host/content views in same container") - return - } - - XCTAssertGreaterThan( - hostIndex, - contentIndex, - "Browser portal host must remain above content view so portal-hosted web views stay visible" - ) - } - - func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - - let browserPortal = WindowBrowserPortal(window: window) - let terminalPortal = WindowTerminalPortal(window: window) - _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) - _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - func assertHostOrder(_ message: String) { - guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), - let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else { - XCTFail("Expected both portal hosts in same container") - return - } - - XCTAssertGreaterThan( - browserHostIndex, - terminalHostIndex, - message - ) - } - - assertHostOrder("Browser portal host should start above terminal portal host") - - let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140)) - contentView.addSubview(terminalAnchor) - let terminalHostedView = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - ) - terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true) - terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor) - assertHostOrder("Terminal portal sync should not rise above the browser portal host") - - let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140)) - contentView.addSubview(browserAnchor) - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true) - browserPortal.synchronizeWebViewForAnchor(browserAnchor) - assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals") - } - - func testAnchorRebindKeepsWebViewInStablePortalSuperview() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) - let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120)) - contentView.addSubview(anchor1) - contentView.addSubview(anchor2) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor1, visibleInUI: true) - let firstSuperview = webView.superview - - XCTAssertNotNil(firstSuperview) - XCTAssertTrue(firstSuperview is WindowBrowserSlotView) - - portal.bind(webView: webView, to: anchor2, visibleInUI: true) - XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view") - - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor2) - guard let slot = webView.superview as? WindowBrowserSlotView, - let host = slot.superview as? WindowBrowserHostView else { - XCTFail("Expected browser slot + host views") - return - } - let expectedFrame = host.convert(anchor2.bounds, from: anchor2) - XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5) - XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5) - } - - func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - // Simulate a transient oversized anchor rect during split churn. - let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected web view slot") - return - } - - XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible") - XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5) - XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) - } - - func testPortalClipsAnchorFrameThroughAncestorBounds() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120)) - contentView.addSubview(clipView) - - // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane. - let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120)) - clipView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - clipView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane") - XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5) - XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5) - } - - func testPortalSyncNormalizesOutOfBoundsWebFrame() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds. - webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height) - XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY) - - portal.synchronizeWebViewForAnchor(anchor) - XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5) - XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5) - XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5) - XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) - } - - func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) - let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) - let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - slot.addSubview(webView) - slot.addSubview(inspectorContainer) - - webView.translatesAutoresizingMaskIntoConstraints = false - webView.autoresizingMask = [] - slot.pinHostedWebView(webView) - - XCTAssertEqual( - webView.frame.maxX, - inspectorContainer.frame.minX, - accuracy: 0.5, - "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" - ) - XCTAssertLessThan( - webView.frame.width, - slot.bounds.width, - "The page frame should stay narrower than the full slot while a side-docked inspector is present" - ) - } - - func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - let initialInspectorWidth: CGFloat = 110 - let inspectorContainer = NSView( - frame: NSRect( - x: slot.bounds.width - initialInspectorWidth, - y: 0, - width: initialInspectorWidth, - height: slot.bounds.height - ) - ) - inspectorContainer.autoresizingMask = [.minXMargin, .height] - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - slot.addSubview(inspectorContainer) - - webView.frame = NSRect( - x: 0, - y: 0, - width: slot.bounds.width - initialInspectorWidth, - height: slot.bounds.height - ) - webView.autoresizingMask = [.width, .height] - slot.layoutSubtreeIfNeeded() - - anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible") - XCTAssertEqual( - webView.frame.maxX, - inspectorContainer.frame.minX, - accuracy: 0.5, - "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector" - ) - XCTAssertLessThan( - webView.frame.width, - slot.bounds.width, - "Side-docked inspector should still own part of the slot after pane resize" - ) - } - - func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) - contentView.addSubview(anchor) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - let initialDisplayCount = webView.displayIfNeededCount - let initialReattachCount = webView.reattachRenderingStateCount - anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible") - XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5) - XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5) - XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5) - XCTAssertGreaterThan( - webView.displayIfNeededCount, - initialDisplayCount, - "Pure anchor geometry updates should still repaint the hosted browser" - ) - XCTAssertEqual( - webView.reattachRenderingStateCount, - initialReattachCount, - "Pure anchor geometry updates should not trigger the WebKit reattach path" - ) - } - - func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 360), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let splitView = NSSplitView(frame: contentView.bounds) - splitView.autoresizingMask = [.width, .height] - splitView.isVertical = true - - let leadingPane = NSView( - frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height) - ) - leadingPane.autoresizingMask = [.height] - let trailingPane = NSView( - frame: NSRect( - x: 221, - y: 0, - width: contentView.bounds.width - 221, - height: contentView.bounds.height - ) - ) - trailingPane.autoresizingMask = [.width, .height] - splitView.addSubview(leadingPane) - splitView.addSubview(trailingPane) - contentView.addSubview(splitView) - splitView.adjustSubviews() - - let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12)) - anchor.autoresizingMask = [.width, .height] - trailingPane.addSubview(anchor) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - let initialDisplayCount = webView.displayIfNeededCount - let initialReattachCount = webView.reattachRenderingStateCount - let initialWidth = slot.frame.width - - splitView.setPosition(280, ofDividerAt: 0) - contentView.layoutSubtreeIfNeeded() - NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView) - advanceAnimations() - - XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible") - XCTAssertLessThan( - slot.frame.width, - initialWidth, - "Moving the app split divider should shrink the hosted browser slot" - ) - XCTAssertGreaterThan( - webView.displayIfNeededCount, - initialDisplayCount, - "External split resize should still repaint the hosted browser" - ) - XCTAssertEqual( - webView.reattachRenderingStateCount, - initialReattachCount, - "External split resize should not trigger the WebKit reattach path" - ) - } - - func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - let inspectorHeight: CGFloat = 84 - let inspectorContainer = NSView( - frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight) - ) - inspectorContainer.autoresizingMask = [.width] - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - slot.addSubview(inspectorContainer) - - webView.frame = NSRect( - x: 0, - y: inspectorHeight, - width: slot.bounds.width, - height: slot.bounds.height - ) - webView.autoresizingMask = [.width, .height] - slot.layoutSubtreeIfNeeded() - - portal.synchronizeWebViewForAnchor(anchor) - - XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible") - XCTAssertEqual( - webView.frame.minY, - inspectorHeight, - accuracy: 0.5, - "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward" - ) - XCTAssertEqual( - webView.frame.height, - slot.bounds.height - inspectorHeight, - accuracy: 0.5, - "Portal sync should shrink the page viewport to the space above a bottom-docked inspector" - ) - XCTAssertEqual( - webView.frame.maxY, - slot.bounds.maxY, - accuracy: 0.5, - "The repaired page viewport should stay flush with the top edge of the slot" - ) - } - - func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) - contentView.addSubview(slot) - - let inspectorContainer = NSView(frame: slot.bounds) - inspectorContainer.autoresizingMask = [.width, .height] - let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) - inspectorView.autoresizingMask = [.width, .height] - inspectorContainer.addSubview(inspectorView) - slot.addSubview(inspectorContainer) - contentView.layoutSubtreeIfNeeded() - - XCTAssertTrue( - window.makeFirstResponder(inspectorView), - "Precondition failed: inspector probe should become first responder" - ) - XCTAssertTrue(window.firstResponder === inspectorView) - - slot.isHidden = true - - XCTAssertFalse( - window.firstResponder === inspectorView, - "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" - ) - if let firstResponderView = window.firstResponder as? NSView { - XCTAssertFalse( - firstResponderView === slot || firstResponderView.isDescendant(of: slot), - "Hiding a browser slot should not leave first responder inside the hidden slot" - ) - } - } - - func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - contentView.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden") - - let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame) - contentView.addSubview(localInlineSlot) - - let inspectorView = WKInspectorProbeView( - frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72) - ) - inspectorView.autoresizingMask = [.width] - localInlineSlot.addSubview(inspectorView) - - localInlineSlot.addSubview(webView) - webView.frame = NSRect( - x: 0, - y: inspectorView.frame.maxY, - width: localInlineSlot.bounds.width, - height: localInlineSlot.bounds.height - inspectorView.frame.height - ) - localInlineSlot.layoutSubtreeIfNeeded() - - anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) - localInlineSlot.frame = anchor.frame - contentView.layoutSubtreeIfNeeded() - localInlineSlot.layoutSubtreeIfNeeded() - portal.synchronizeWebViewForAnchor(anchor) - - XCTAssertTrue( - webView.superview === localInlineSlot, - "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize" - ) - XCTAssertTrue( - inspectorView.superview === localInlineSlot, - "Hidden portal sync should leave local DevTools companion views in the local inline host" - ) - XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting") - } - - func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView, - let host = slot.superview as? WindowBrowserHostView else { - XCTFail("Expected portal slot + host views") - return - } - XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync") - XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") - } - - func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor) - - guard let slot = webView.superview as? WindowBrowserSlotView, - let overlay = dropZoneOverlay(in: slot, excluding: webView) else { - XCTFail("Expected browser slot overlay") - return - } - - XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone") - - portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) - slot.layoutSubtreeIfNeeded() - XCTAssertFalse(overlay.isHidden) - XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") - XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) - XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) - XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay") - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) - portal.synchronizeWebViewForAnchor(anchor) - XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay") - } - - func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) - contentView.addSubview(anchor) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - let initialDisplayCount = webView.displayIfNeededCount - let initialReattachCount = webView.reattachRenderingStateCount - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - let hiddenDisplayCount = webView.displayIfNeededCount - let hiddenReattachCount = webView.reattachRenderingStateCount - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) - XCTAssertEqual( - hiddenReattachCount, - initialReattachCount, - "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path" - ) - XCTAssertGreaterThan( - webView.displayIfNeededCount, - hiddenDisplayCount, - "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" - ) - XCTAssertGreaterThan( - webView.reattachRenderingStateCount, - hiddenReattachCount, - "Revealing an existing portal-hosted browser should trigger the WebKit reattach path" - ) - } - - func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) - let anchor1 = NSView(frame: anchorFrame) - contentView.addSubview(anchor1) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor1, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor1) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - anchor1.removeFromSuperview() - portal.synchronizeWebViewForAnchor(anchor1) - advanceAnimations() - - XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") - XCTAssertTrue( - slot.isHidden, - "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" - ) - XCTAssertEqual(portal.debugEntryCount(), 1) - - let displayCountBeforeRebind = webView.displayIfNeededCount - let anchor2 = NSView(frame: anchorFrame) - contentView.addSubview(anchor2) - portal.bind(webView: webView, to: anchor2, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor2) - advanceAnimations() - - XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") - XCTAssertFalse(slot.isHidden) - XCTAssertEqual(portal.debugEntryCount(), 1) - XCTAssertGreaterThan( - webView.displayIfNeededCount, - displayCountBeforeRebind, - "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" - ) - } - - func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) - let anchor = NSView(frame: anchorFrame) - contentView.addSubview(anchor) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: anchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - let offWindowContainer = NSView(frame: anchorFrame) - anchor.removeFromSuperview() - offWindowContainer.addSubview(anchor) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - XCTAssertTrue( - webView.superview === slot, - "Off-window anchor reparent should preserve the hosted browser slot during drag churn" - ) - XCTAssertFalse( - slot.isHidden, - "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns" - ) - XCTAssertEqual(portal.debugEntryCount(), 1) - - contentView.addSubview(anchor) - portal.synchronizeWebViewForAnchor(anchor) - advanceAnimations() - - XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot") - XCTAssertFalse(slot.isHidden) - XCTAssertEqual(portal.debugEntryCount(), 1) - } - - func testRegistryDetachRemovesPortalHostedWebView() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) - contentView.addSubview(anchor) - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) - XCTAssertNotNil(webView.superview) - - BrowserWindowPortalRegistry.detach(webView: webView) - XCTAssertNil(webView.superview) - } - - func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) - contentView.addSubview(anchor) - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - XCTAssertFalse(slot.isHidden) - - BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") - advanceAnimations() - - XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") - XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") - } - - func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { window.orderOut(nil) } - realizeWindowLayout(window) - let portal = WindowBrowserPortal(window: window) - - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) - let oldAnchor = NSView(frame: anchorFrame) - contentView.addSubview(oldAnchor) - - let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) - portal.bind(webView: webView, to: oldAnchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(oldAnchor) - advanceAnimations() - - guard let slot = webView.superview as? WindowBrowserSlotView else { - XCTFail("Expected browser slot") - return - } - - portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) - portal.synchronizeWebViewForAnchor(oldAnchor) - advanceAnimations() - XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount") - - oldAnchor.removeFromSuperview() - portal.synchronizeWebViewForAnchor(oldAnchor) - advanceAnimations() - - XCTAssertTrue( - webView.superview === slot, - "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted" - ) - XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound") - XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive") - - let displayCountBeforeRebind = webView.displayIfNeededCount - let newAnchor = NSView(frame: anchorFrame) - contentView.addSubview(newAnchor) - portal.bind(webView: webView, to: newAnchor, visibleInUI: true) - portal.synchronizeWebViewForAnchor(newAnchor) - advanceAnimations() - - XCTAssertTrue( - webView.superview === slot, - "Selecting the workspace again should reuse the existing hidden browser portal slot" - ) - XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot") - XCTAssertEqual(portal.debugEntryCount(), 1) - XCTAssertGreaterThan( - webView.displayIfNeededCount, - displayCountBeforeRebind, - "Workspace rebind should refresh the preserved browser without recreating its portal slot" - ) - } -} - -@MainActor -final class FileDropOverlayViewTests: XCTestCase { - private func realizeWindowLayout(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - window.contentView?.layoutSubtreeIfNeeded() - } - - func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - realizeWindowLayout(window) - - guard let contentView = window.contentView, - let container = contentView.superview else { - XCTFail("Expected content container") - return - } - - let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) - contentView.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - - let overlay = FileDropOverlayView(frame: container.bounds) - overlay.autoresizingMask = [.width, .height] - container.addSubview(overlay, positioned: .above, relativeTo: nil) - - let point = anchor.convert( - NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), - to: nil - ) - XCTAssertTrue( - overlay.webViewUnderPoint(point) === webView, - "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" - ) - } -} - -@MainActor -final class MarkdownPanelPointerObserverViewTests: XCTestCase { - private func makeWindow() -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - return window - } - - private func makeMouseEvent( - type: NSEvent.EventType, - location: NSPoint, - window: NSWindow, - eventNumber: Int = 1 - ) -> NSEvent { - guard let event = NSEvent.mouseEvent( - with: type, - location: location, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: eventNumber, - clickCount: 1, - pressure: 1.0 - ) else { - fatalError("Expected to create mouse event") - } - return event - } - - func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { - let window = makeWindow() - defer { window.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) - overlay.autoresizingMask = [.width, .height] - let focusExpectation = expectation(description: "observer forwards focus callback") - var pointerDownCount = 0 - overlay.onPointerDown = { - pointerDownCount += 1 - focusExpectation.fulfill() - } - contentView.addSubview(overlay) - - _ = overlay.handleEventIfNeeded( - makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) - ) - wait(for: [focusExpectation], timeout: 1.0) - - XCTAssertEqual(pointerDownCount, 1) - } - - func testObserverIgnoresOutsideOrForeignWindowClicks() { - let window = makeWindow() - defer { window.orderOut(nil) } - let otherWindow = makeWindow() - defer { otherWindow.orderOut(nil) } - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) - overlay.autoresizingMask = [.width, .height] - let noFocusExpectation = expectation(description: "observer ignores invalid clicks") - noFocusExpectation.isInverted = true - var pointerDownCount = 0 - overlay.onPointerDown = { - pointerDownCount += 1 - noFocusExpectation.fulfill() - } - contentView.addSubview(overlay) - - _ = overlay.handleEventIfNeeded( - makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) - ) - _ = overlay.handleEventIfNeeded( - makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) - ) - _ = overlay.handleEventIfNeeded( - makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) - ) - wait(for: [noFocusExpectation], timeout: 0.1) - - XCTAssertEqual(pointerDownCount, 0) - } - - func testObserverDoesNotParticipateInHitTesting() { - let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) - XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) - } -} - -final class BrowserLinkOpenSettingsTests: XCTestCase { - private var suiteName: String! - private var defaults: UserDefaults! - - override func setUp() { - super.setUp() - suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) - defaults.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() - } - - func testTerminalLinksDefaultToCmuxBrowser() { - XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) - } - - func testTerminalLinksPreferenceUsesStoredValue() { - defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) - - defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) - } - - func testSidebarPullRequestLinksDefaultToCmuxBrowser() { - XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) - } - - func testSidebarPullRequestLinksPreferenceUsesStoredValue() { - defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) - XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) - - defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) - XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) - } - - func testOpenCommandInterceptionDefaultsToCmuxBrowser() { - XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) - } - - func testOpenCommandInterceptionUsesStoredValue() { - defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) - XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) - - defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) - XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) - } - - func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { - defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) - - defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) - } - - func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() { - defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) - - defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) - XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) - } - - func testExternalOpenPatternsDefaultToEmpty() { - XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty) - } - - func testExternalOpenLiteralPatternMatchesCaseInsensitively() { - defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) - XCTAssertTrue( - BrowserLinkOpenSettings.shouldOpenExternally( - "https://platform.OPENAI.com/account/usage", - defaults: defaults - ) - ) - } - - func testExternalOpenRegexPatternMatchesCaseInsensitively() { - defaults.set( - "re:^https?://[^/]*\\.example\\.com/(billing|usage)", - forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey - ) - XCTAssertTrue( - BrowserLinkOpenSettings.shouldOpenExternally( - "https://FOO.example.com/BILLING", - defaults: defaults - ) - ) - } - - func testExternalOpenRegexPatternSupportsDigitCharacterClass() { - defaults.set( - "re:^https://example\\.com/usage/\\d+$", - forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey - ) - XCTAssertTrue( - BrowserLinkOpenSettings.shouldOpenExternally( - "https://example.com/usage/42", - defaults: defaults - ) - ) - } - - func testExternalOpenPatternsIgnoreInvalidRegexEntries() { - defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) - XCTAssertTrue( - BrowserLinkOpenSettings.shouldOpenExternally( - "https://example.com/path", - defaults: defaults - ) - ) - } -} - -final class TerminalOpenURLTargetResolutionTests: XCTestCase { - func testResolvesHTTPSAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1")) - switch target { - case let .embeddedBrowser(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertEqual(url.host, "example.com") - XCTAssertEqual(url.path, "/path") - default: - XCTFail("Expected web URL to route to embedded browser") - } - } - - func testResolvesBareDomainAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs")) - switch target { - case let .embeddedBrowser(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertEqual(url.host, "example.com") - XCTAssertEqual(url.path, "/docs") - default: - XCTFail("Expected bare domain to be normalized as an HTTPS browser URL") - } - } - - func testResolvesFileSchemeAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt")) - switch target { - case let .external(url): - XCTAssertTrue(url.isFileURL) - XCTAssertEqual(url.path, "/tmp/cmux.txt") - default: - XCTFail("Expected file URL to open externally") - } - } - - func testResolvesAbsolutePathAsExternalFileURL() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt")) - switch target { - case let .external(url): - XCTAssertTrue(url.isFileURL) - XCTAssertEqual(url.path, "/tmp/cmux-path.txt") - default: - XCTFail("Expected absolute file path to open externally") - } - } - - func testResolvesNonWebSchemeAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com")) - switch target { - case let .external(url): - XCTAssertEqual(url.scheme, "mailto") - default: - XCTFail("Expected non-web scheme to open externally") - } - } - - func testResolvesHostlessHTTPSAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt")) - switch target { - case let .external(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertNil(url.host) - XCTAssertEqual(url.path, "/tmp/cmux.txt") - default: - XCTFail("Expected hostless HTTPS URL to open externally") - } - } -} - -final class BrowserNavigableURLResolutionTests: XCTestCase { - func testResolvesFileSchemeAsNavigableURL() throws { - let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html")) - XCTAssertTrue(resolved.isFileURL) - XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html") - } - - func testRejectsNonWebNonFileScheme() { - XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com")) - XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html")) - } - - func testRejectsHostOnlyFileURL() { - XCTAssertNil(resolveBrowserNavigableURL("file://example.html")) - } -} - -final class BrowserReadAccessURLTests: XCTestCase { - func testUsesParentDirectoryForFileURL() throws { - let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) - let file = dir.appendingPathComponent("sample.html") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - try "".write(to: file, atomically: true, encoding: .utf8) - - let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file)) - XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) - } - - func testUsesDirectoryURLWhenTargetIsDirectory() throws { - let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - - let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir)) - XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL) - } - - func testUsesParentDirectoryWhenFileDoesNotExist() throws { - let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html") - let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing)) - XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL) - } - - func testReturnsNilForHostOnlyFileURL() throws { - let hostOnly = try XCTUnwrap(URL(string: "file://example.html")) - XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly)) - } -} - -final class BrowserExternalNavigationSchemeTests: XCTestCase { - func testCustomAppSchemesOpenExternally() throws { - let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) - let slack = try XCTUnwrap(URL(string: "slack://open")) - let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) - let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) - - XCTAssertTrue(browserShouldOpenURLExternally(discord)) - XCTAssertTrue(browserShouldOpenURLExternally(slack)) - XCTAssertTrue(browserShouldOpenURLExternally(zoom)) - XCTAssertTrue(browserShouldOpenURLExternally(mailto)) - } - - func testEmbeddedBrowserSchemesStayInWebView() throws { - let https = try XCTUnwrap(URL(string: "https://example.com")) - let http = try XCTUnwrap(URL(string: "http://example.com")) - let about = try XCTUnwrap(URL(string: "about:blank")) - let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) - let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html")) - let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) - let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) - let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) - - XCTAssertFalse(browserShouldOpenURLExternally(https)) - XCTAssertFalse(browserShouldOpenURLExternally(http)) - XCTAssertFalse(browserShouldOpenURLExternally(about)) - XCTAssertFalse(browserShouldOpenURLExternally(data)) - XCTAssertFalse(browserShouldOpenURLExternally(file)) - XCTAssertFalse(browserShouldOpenURLExternally(blob)) - XCTAssertFalse(browserShouldOpenURLExternally(javascript)) - XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) - } -} - -final class BrowserHostWhitelistTests: XCTestCase { - private var suiteName: String! - private var defaults: UserDefaults! - - override func setUp() { - super.setUp() - suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) - defaults.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() - } - - func testEmptyWhitelistAllowsAll() { - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - } - - func testExactMatch() { - defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - } - - func testExactMatchIsCaseInsensitive() { - defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) - } - - func testWildcardSuffix() { - defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - } - - func testWildcardIsCaseInsensitive() { - defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) - } - - func testBlankLinesAndWhitespaceIgnored() { - defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - } - - func testMixedExactAndWildcard() { - defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) - } - - func testDefaultWhitelistIsEmpty() { - let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults) - XCTAssertTrue(patterns.isEmpty) - } - - func testWildcardRequiresDotBoundary() { - defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) - } - - func testWhitelistNormalizesSchemesPortsAndTrailingDots() { - defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) - } - - func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { - defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - } - - func testUnicodeWhitelistEntryMatchesPunycodeHost() { - defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) - } -} - -final class TerminalControllerSidebarDedupeTests: XCTestCase { - func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() { - let current = SidebarStatusEntry( - key: "agent", - value: "idle", - icon: "bolt", - color: "#ffffff", - timestamp: Date(timeIntervalSince1970: 123) - ) - XCTAssertFalse( - TerminalController.shouldReplaceStatusEntry( - current: current, - key: "agent", - value: "idle", - icon: "bolt", - color: "#ffffff", - url: nil, - priority: 0, - format: .plain - ) - ) - } - - func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() { - let current = SidebarStatusEntry( - key: "agent", - value: "idle", - icon: "bolt", - color: "#ffffff", - timestamp: Date(timeIntervalSince1970: 123) - ) - XCTAssertTrue( - TerminalController.shouldReplaceStatusEntry( - current: current, - key: "agent", - value: "running", - icon: "bolt", - color: "#ffffff", - url: nil, - priority: 0, - format: .plain - ) - ) - } - - func testShouldReplaceProgressReturnsFalseForUnchangedPayload() { - XCTAssertFalse( - TerminalController.shouldReplaceProgress( - current: SidebarProgressState(value: 0.42, label: "indexing"), - value: 0.42, - label: "indexing" - ) - ) - } - - func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() { - XCTAssertFalse( - TerminalController.shouldReplaceGitBranch( - current: SidebarGitBranchState(branch: "main", isDirty: true), - branch: "main", - isDirty: true - ) - ) - } - - func testShouldReplacePortsIgnoresOrderAndDuplicates() { - XCTAssertFalse( - TerminalController.shouldReplacePorts( - current: [9229, 3000], - next: [3000, 9229, 3000] - ) - ) - XCTAssertTrue( - TerminalController.shouldReplacePorts( - current: [9229, 3000], - next: [3000] - ) - ) - } - - func testExplicitSocketScopeParsesValidUUIDTabAndPanel() { - let workspaceId = UUID() - let panelId = UUID() - let scope = TerminalController.explicitSocketScope( - options: [ - "tab": workspaceId.uuidString, - "panel": panelId.uuidString - ] - ) - XCTAssertEqual(scope?.workspaceId, workspaceId) - XCTAssertEqual(scope?.panelId, panelId) - } - - func testExplicitSocketScopeAcceptsSurfaceAlias() { - let workspaceId = UUID() - let panelId = UUID() - let scope = TerminalController.explicitSocketScope( - options: [ - "tab": workspaceId.uuidString, - "surface": panelId.uuidString - ] - ) - XCTAssertEqual(scope?.workspaceId, workspaceId) - XCTAssertEqual(scope?.panelId, panelId) - } - - func testExplicitSocketScopeRejectsMissingOrInvalidValues() { - XCTAssertNil(TerminalController.explicitSocketScope(options: [:])) - XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString])) - XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"])) - } - - func testNormalizeReportedDirectoryTrimsWhitespace() { - XCTAssertEqual( - TerminalController.normalizeReportedDirectory(" /Users/cmux/project "), - "/Users/cmux/project" - ) - } - - func testNormalizeReportedDirectoryResolvesFileURL() { - XCTAssertEqual( - TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"), - "/Users/cmux/project" - ) - } - - func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() { - XCTAssertEqual( - TerminalController.normalizeReportedDirectory(" file://bad host "), - "file://bad host" - ) - } -} - -final class TerminalControllerSocketTextChunkTests: XCTestCase { - func testSocketTextChunksReturnsSingleChunkForPlainText() { - XCTAssertEqual( - TerminalController.socketTextChunks("echo hello"), - [.text("echo hello")] - ) - } - - func testSocketTextChunksSplitsControlScalars() { - XCTAssertEqual( - TerminalController.socketTextChunks("abc\rdef\tghi"), - [ - .text("abc"), - .control("\r".unicodeScalars.first!), - .text("def"), - .control("\t".unicodeScalars.first!), - .text("ghi") - ] - ) - } - - func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() { - XCTAssertEqual( - TerminalController.socketTextChunks("\r\n\t"), - [ - .control("\r".unicodeScalars.first!), - .control("\n".unicodeScalars.first!), - .control("\t".unicodeScalars.first!) - ] - ) - } -} - -final class BrowserOmnibarFocusPolicyTests: XCTestCase { - func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { - XCTAssertTrue( - browserOmnibarShouldReacquireFocusAfterEndEditing( - desiredOmnibarFocus: true, - nextResponderIsOtherTextField: false - ) - ) - } - - func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { - XCTAssertFalse( - browserOmnibarShouldReacquireFocusAfterEndEditing( - desiredOmnibarFocus: true, - nextResponderIsOtherTextField: true - ) - ) - } - - func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { - XCTAssertFalse( - browserOmnibarShouldReacquireFocusAfterEndEditing( - desiredOmnibarFocus: false, - nextResponderIsOtherTextField: false - ) - ) - } -} - -final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { - func testImmediateStateUpdateAllowedWhenHostNotInWindow() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: false - ) - ) - } - - func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: true - ) - ) - } - - func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { - XCTAssertFalse( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: true, - isBoundToCurrentHost: false - ) - ) - } - - func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { - XCTAssertTrue( - GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( - hostedViewHasSuperview: false, - isBoundToCurrentHost: false - ) - ) - } -} - -final class TerminalControllerSocketListenerHealthTests: XCTestCase { - func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { - XCTAssertEqual( - TerminalController.fallbackSocketPathAfterBindFailure( - requestedPath: SocketControlSettings.stableDefaultSocketPath, - stage: "bind", - errnoCode: EACCES, - currentUserID: 501 - ), - SocketControlSettings.userScopedStableSocketPath(currentUserID: 501) - ) - } - - func testNonStableSocketBindFailureDoesNotFallback() { - XCTAssertNil( - TerminalController.fallbackSocketPathAfterBindFailure( - requestedPath: "/tmp/cmux-debug.sock", - stage: "bind", - errnoCode: EACCES, - currentUserID: 501 - ) - ) - } - - private func makeTempSocketPath() -> String { - "/tmp/cmux-socket-health-\(UUID().uuidString).sock" - } - - private func bindUnixSocket(at path: String) throws -> Int32 { - unlink(path) - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(errno), - userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] - ) - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strcpy(pathBuf, ptr) - } - } - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { - let code = Int(errno) - Darwin.close(fd) - throw NSError( - domain: NSPOSIXErrorDomain, - code: code, - userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] - ) - } - - guard Darwin.listen(fd, 1) == 0 else { - let code = Int(errno) - Darwin.close(fd) - throw NSError( - domain: NSPOSIXErrorDomain, - code: code, - userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] - ) - } - - return fd - } - - private func acceptSingleClient( - on listenerFD: Int32, - handler: @escaping (_ clientFD: Int32) -> Void - ) -> XCTestExpectation { - let handled = expectation(description: "socket client handled") - DispatchQueue.global(qos: .userInitiated).async { - var clientAddr = sockaddr_un() - var clientAddrLen = socklen_t(MemoryLayout.size) - let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) - } - } - guard clientFD >= 0 else { - handled.fulfill() - return - } - defer { - Darwin.close(clientFD) - handled.fulfill() - } - handler(clientFD) - } - return handled - } - - @MainActor - func testSocketListenerHealthRecognizesSocketPath() throws { - let path = makeTempSocketPath() - let fd = try bindUnixSocket(at: path) - defer { - Darwin.close(fd) - unlink(path) - } - - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) - XCTAssertTrue(health.socketPathExists) - XCTAssertFalse(health.isHealthy) - } - - @MainActor - func testSocketListenerHealthRejectsRegularFile() throws { - let path = makeTempSocketPath() - let url = URL(fileURLWithPath: path) - try "not-a-socket".write(to: url, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: url) } - - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) - XCTAssertFalse(health.socketPathExists) - XCTAssertFalse(health.isHealthy) - } - - func testProbeSocketCommandReturnsFirstLineResponse() throws { - let path = makeTempSocketPath() - let listenerFD = try bindUnixSocket(at: path) - defer { - Darwin.close(listenerFD) - unlink(path) - } - - let handled = acceptSingleClient(on: listenerFD) { clientFD in - var buffer = [UInt8](repeating: 0, count: 256) - _ = read(clientFD, &buffer, buffer.count) - let response = "PONG\nextra\n" - _ = response.withCString { ptr in - write(clientFD, ptr, strlen(ptr)) - } - } - - let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) - - XCTAssertEqual(response, "PONG") - wait(for: [handled], timeout: 1.0) - } - - func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { - let path = makeTempSocketPath() - let listenerFD = try bindUnixSocket(at: path) - defer { - Darwin.close(listenerFD) - unlink(path) - } - - let releaseServer = DispatchSemaphore(value: 0) - let handled = acceptSingleClient(on: listenerFD) { clientFD in - var buffer = [UInt8](repeating: 0, count: 256) - _ = read(clientFD, &buffer, buffer.count) - _ = releaseServer.wait(timeout: .now() + 1.0) - } - - let startedAt = Date() - let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) - let elapsed = Date().timeIntervalSince(startedAt) - releaseServer.signal() - - XCTAssertNil(response) - XCTAssertGreaterThanOrEqual(elapsed, 0.18) - XCTAssertLessThan(elapsed, 0.8) - wait(for: [handled], timeout: 1.0) - } - - func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { - let health = TerminalController.SocketListenerHealth( - isRunning: true, - acceptLoopAlive: true, - socketPathMatches: true, - socketPathExists: true - ) - XCTAssertTrue(health.isHealthy) - XCTAssertTrue(health.failureSignals.isEmpty) - } - - func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { - let health = TerminalController.SocketListenerHealth( - isRunning: false, - acceptLoopAlive: false, - socketPathMatches: false, - socketPathExists: false - ) - XCTAssertFalse(health.isHealthy) - XCTAssertEqual( - health.failureSignals, - ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] - ) - } -} diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 51cd4182..b0c8d2e0 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -116,6 +116,29 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ] } + private func makeUpdateCommandEntries() -> [FixtureEntry] { + [ + FixtureEntry( + id: "command.checkForUpdates", + rank: 0, + title: "Check for Updates", + searchableTexts: ["Check for Updates", "Global", "update", "upgrade", "release"] + ), + FixtureEntry( + id: "command.attemptUpdate", + rank: 1, + title: "Attempt Update", + searchableTexts: ["Attempt Update", "Global", "attempt", "check", "update", "upgrade", "release"] + ), + FixtureEntry( + id: "command.applyUpdateIfAvailable", + rank: 2, + title: "Apply Update (If Available)", + searchableTexts: ["Apply Update (If Available)", "Global", "apply", "install", "update", "available"] + ), + ] + } + private func optimizedResults( entries: [FixtureEntry], query: String @@ -141,7 +164,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - private func legacyResults( + private func referenceResults( entries: [FixtureEntry], query: String ) -> [FixtureResult] { @@ -151,9 +174,9 @@ final class CommandPaletteSearchEngineTests: XCTestCase { FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: []) } : entries.compactMap { entry in - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + guard let fuzzyScore = weightedReferenceScore( query: query, - candidates: entry.searchableTexts + entry: entry ) else { return nil } @@ -163,7 +186,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { title: entry.title, score: fuzzyScore, titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - query: query, + query: query, candidate: entry.title ) ) @@ -176,6 +199,25 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } + private func weightedReferenceScore( + query: String, + entry: FixtureEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidates: entry.searchableTexts + ) else { + return nil + } + guard let titleScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidate: entry.title + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + 2000) + } + private func benchmarkElapsedMs(operation: () -> Void) -> Double { let start = DispatchTime.now().uptimeNanoseconds operation() @@ -187,7 +229,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { Array(repeating: baseQueries, count: repetitions).flatMap { $0 } } - func testOptimizedSearchMatchesLegacyPipeline() { + func testOptimizedSearchMatchesReferencePipeline() { let commandEntries = makeCommandEntries(count: 96) let switcherEntries = makeSwitcherEntries(count: 64) let queries = [ @@ -205,12 +247,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { for query in queries { XCTAssertEqual( optimizedResults(entries: commandEntries, query: query), - legacyResults(entries: commandEntries, query: query), + referenceResults(entries: commandEntries, query: query), "Command corpus mismatch for query \(query)" ) XCTAssertEqual( optimizedResults(entries: switcherEntries, query: query), - legacyResults(entries: switcherEntries, query: query), + referenceResults(entries: switcherEntries, query: query), "Switcher corpus mismatch for query \(query)" ) } @@ -323,6 +365,15 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } + func testSearchPrefersTitleMatchOverKeywordOnlyMatchForCheckQuery() { + let results = optimizedResults(entries: makeUpdateCommandEntries(), query: "check") + + XCTAssertEqual( + results.prefix(2).map(\.id), + ["command.checkForUpdates", "command.attemptUpdate"] + ) + } + func testResolvedSelectionIndexPrefersAnchoredCommand() { let resultIDs = ["command.0", "command.1", "command.2"] @@ -779,13 +830,13 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) for query in queries.prefix(8) { - _ = legacyResults(entries: entries, query: query) + _ = referenceResults(entries: entries, query: query) _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } } - let legacyMs = benchmarkElapsedMs { + let referenceMs = benchmarkElapsedMs { for query in queries { - _ = legacyResults(entries: entries, query: query) + _ = referenceResults(entries: entries, query: query) } } let optimizedMs = benchmarkElapsedMs { @@ -794,11 +845,11 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + print(String(format: "BENCH cmd+shift+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) XCTAssertLessThan( optimizedMs, - legacyMs * 1.25, - "Optimized command search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + referenceMs * 1.25, + "Optimized command search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)" ) } @@ -818,13 +869,13 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) for query in queries.prefix(8) { - _ = legacyResults(entries: entries, query: query) + _ = referenceResults(entries: entries, query: query) _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } } - let legacyMs = benchmarkElapsedMs { + let referenceMs = benchmarkElapsedMs { for query in queries { - _ = legacyResults(entries: entries, query: query) + _ = referenceResults(entries: entries, query: query) } } let optimizedMs = benchmarkElapsedMs { @@ -833,11 +884,11 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } - print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + print(String(format: "BENCH cmd+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) XCTAssertLessThan( optimizedMs, - legacyMs * 1.25, - "Optimized switcher search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + referenceMs * 1.25, + "Optimized switcher search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)" ) } } 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/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift new file mode 100644 index 00000000..c519e3ab --- /dev/null +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -0,0 +1,832 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class NotificationDockBadgeTests: XCTestCase { + private final class NotificationSettingsAlertSpy: NSAlert { + private(set) var beginSheetModalCallCount = 0 + private(set) var runModalCallCount = 0 + var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + beginSheetModalCallCount += 1 + handler?(nextResponse) + } + + override func runModal() -> NSApplication.ModalResponse { + runModalCallCount += 1 + return nextResponse + } + } + + override func tearDown() { + TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() + TerminalNotificationStore.shared.replaceNotificationsForTesting([]) + super.tearDown() + } + + func testDockBadgeLabelEnabledAndCounted() { + XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1") + XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42") + XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+") + } + + func testDockBadgeLabelHiddenWhenDisabledOrZero() { + XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true)) + XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false)) + } + + func testDockBadgeLabelShowsRunTagEvenWithoutUnread() { + XCTAssertEqual( + TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"), + "verify-tag" + ) + } + + func testDockBadgeLabelCombinesRunTagAndUnreadCount() { + XCTAssertEqual( + TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"), + "verify:7" + ) + XCTAssertEqual( + TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"), + "verify:99+" + ) + } + + func testNotificationBadgePreferenceDefaultsToEnabled() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) + + defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey) + XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) + + defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey) + XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) + } + + func testNotificationPaneFlashPreferenceDefaultsToEnabled() { + let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + + defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey) + XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey) + XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + } + + func testMenuBarExtraPreferenceDefaultsToVisible() { + let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + + defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey) + XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + + defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey) + XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + } + + func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomFileURLExpandsTildePath() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let rawPath = "~/Library/Sounds/my-custom.wav" + defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) + let expectedPath = (rawPath as NSString).expandingTildeInPath + XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) + } + + func testNotificationCustomFileSelectionMustBeExplicit() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + } + + func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.source-\(UUID().uuidString).wav", + isDirectory: false + ) + defer { + try? fileManager.removeItem(at: sourceURL) + } + + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + _ = NotificationSoundSettings.sound(defaults: defaults) + + guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else { + XCTFail("Expected staged custom sound name") + return + } + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + defer { + try? fileManager.removeItem(at: stagedURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedName.hasSuffix(".wav")) + } + + func testNotificationCustomUnsupportedExtensionsStageAsCaf() { + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"), + "wav" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"), + "aiff" + ) + + let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3") + let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3") + let stagedA = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceA, + destinationExtension: "caf" + ) + let stagedB = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceB, + destinationExtension: "caf" + ) + XCTAssertNotEqual(stagedA, stagedB) + XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedA.hasSuffix(".caf")) + } + + func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav", + isDirectory: false + ) + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + defer { + try? fileManager.removeItem(at: sourceURL) + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path) + let stagedName: String + switch prepareResult { + case .success(let name): + stagedName = name + case .failure(let issue): + XCTFail("Expected custom sound preparation success, got \(issue)") + return + } + + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + let metadataURL = stagedURL.appendingPathExtension("source-metadata") + defer { + try? fileManager.removeItem(at: stagedURL) + try? fileManager.removeItem(at: metadataURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path)) + } + + func testNotificationCustomSoundReturnsNilWhenPreparationFails() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let invalidSourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false) + defer { + try? FileManager.default.removeItem(at: invalidSourceURL) + let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false) + try? FileManager.default.removeItem(at: stagedURL) + } + + do { + try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic) + } catch { + XCTFail("Failed to write invalid custom sound source: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomPreparationReportsMissingFile() { + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false) + .path + + let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath) + switch result { + case .success: + XCTFail("Expected missing file failure") + case .failure(let issue): + guard case .missingFile = issue else { + XCTFail("Expected missingFile issue, got \(issue)") + return + } + } + } + + func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() { + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional) + } + + func testNotificationAuthorizationStateDeliveryCapability() { + XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery) + } + + func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() { + XCTAssertTrue( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: true + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .authorized, + isAppActive: false + ) + ) + } + + func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() { + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: false, + hasRequestedAutomaticAuthorization: true + ) + ) + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: true + ) + ) + } + + func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + var openedURL: URL? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { window }, + alertFactory: { alertSpy }, + scheduler: { _, block in block() }, + urlOpener: { openedURL = $0 } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual( + openedURL?.absoluteString, + "x-apple.systempreferences:com.apple.preference.notifications" + ) + } + + func testNotificationSettingsPromptRetriesUntilWindowExists() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + alertSpy.nextResponse = .alertSecondButtonReturn + + var queuedRetryBlocks: [() -> Void] = [] + var promptWindow: NSWindow? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { promptWindow }, + alertFactory: { alertSpy }, + scheduler: { _, block in queuedRetryBlocks.append(block) }, + urlOpener: { _ in XCTFail("Should not open settings for Not Now response") } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual(queuedRetryBlocks.count, 1) + + promptWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + queuedRetryBlocks.removeFirst()() + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + } + + func testNotificationIndexesTrackUnreadCountsByTabAndSurface() { + let tabA = UUID() + let tabB = UUID() + let surfaceA = UUID() + let surfaceB = UUID() + let notificationAUnread = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceA, + title: "A unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let notificationARead = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceB, + title: "A read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + let notificationBUnread = TerminalNotification( + id: UUID(), + tabId: tabB, + surfaceId: nil, + title: "B unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([ + notificationAUnread, + notificationARead, + notificationBUnread + ]) + + XCTAssertEqual(store.unreadCount, 2) + XCTAssertEqual(store.unreadCount(forTabId: tabA), 1) + XCTAssertEqual(store.unreadCount(forTabId: tabB), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB)) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil)) + XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id) + XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) + } + + func testNotificationIndexesUpdateAfterReadAndClearMutations() { + let tab = UUID() + let surfaceUnread = UUID() + let surfaceRead = UUID() + let unreadNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceUnread, + title: "Unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let readNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceRead, + title: "Read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([unreadNotification, readNotification]) + XCTAssertEqual(store.unreadCount(forTabId: tab), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + + store.markRead(forTabId: tab, surfaceId: surfaceUnread) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id) + + store.clearNotifications(forTabId: tab) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertNil(store.latestNotification(forTabId: tab)) + } +} + + +final class MenuBarBadgeLabelFormatterTests: XCTestCase { + func testBadgeLabelFormatting() { + XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0)) + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+") + } +} + + +final class NotificationMenuSnapshotBuilderTests: XCTestCase { + func testSnapshotCountsUnreadAndLimitsRecentItems() { + let notifications = (0..<8).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "N\(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: index.isMultiple(of: 2) + ) + } + + let snapshot = NotificationMenuSnapshotBuilder.make( + notifications: notifications, + maxInlineNotificationItems: 3 + ) + + XCTAssertEqual(snapshot.unreadCount, 4) + XCTAssertTrue(snapshot.hasNotifications) + XCTAssertTrue(snapshot.hasUnreadNotifications) + XCTAssertEqual(snapshot.recentNotifications.count, 3) + XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id)) + } + + func testStateHintTitleHandlesSingularPluralAndZero() { + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications") + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification") + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications") + } +} + + +final class MenuBarBuildHintFormatterTests: XCTestCase { + func testReleaseBuildShowsNoHint() { + XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false)) + } + + func testDebugBuildWithTagShowsTag() { + XCTAssertEqual( + MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true), + "Build Tag: menubar-extra" + ) + } + + func testDebugBuildWithoutTagShowsUntagged() { + XCTAssertEqual( + MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true), + "Build: DEV (untagged)" + ) + } +} + + +final class MenuBarNotificationLineFormatterTests: XCTestCase { + func testPlainTitleContainsUnreadDotBodyAndTab() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Build finished", + subtitle: "", + body: "All checks passed", + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1") + XCTAssertTrue(line.hasPrefix("● Build finished")) + XCTAssertTrue(line.contains("All checks passed")) + XCTAssertTrue(line.contains("workspace-1")) + } + + func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Deploy", + subtitle: "staging", + body: "", + createdAt: Date(timeIntervalSince1970: 0), + isRead: true + ) + + let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil) + XCTAssertTrue(line.hasPrefix(" Deploy")) + XCTAssertTrue(line.contains("staging")) + } + + func testMenuTitleWrapsAndTruncatesToThreeLines() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Extremely long notification title for wrapping behavior validation", + subtitle: "", + body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "), + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let title = MenuBarNotificationLineFormatter.menuTitle( + notification: notification, + tabTitle: "workspace-with-a-very-long-name", + maxWidth: 120, + maxLines: 3 + ) + + XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3) + XCTAssertTrue(title.hasSuffix("…")) + } + + func testMenuTitlePreservesShortTextWithoutEllipsis() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Done", + subtitle: "", + body: "All checks passed", + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let title = MenuBarNotificationLineFormatter.menuTitle( + notification: notification, + tabTitle: "w1", + maxWidth: 320, + maxLines: 3 + ) + + XCTAssertFalse(title.hasSuffix("…")) + } +} + + +final class MenuBarIconDebugSettingsTests: XCTestCase { + func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() { + let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey) + defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey) + + XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7) + } + + func testBadgeRenderConfigClampsInvalidValues() { + let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey) + defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey) + defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey) + defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey) + + let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) + XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001) + XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001) + XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001) + XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001) + } + + func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() { + let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey) + + let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) + XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001) + } +} + +@MainActor + + +final class MenuBarIconRendererTests: XCTestCase { + func testImageWidthDoesNotShiftWhenBadgeAppears() { + let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0) + let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2) + + XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001) + XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) + } +} diff --git a/cmuxTests/OmnibarAndToolsTests.swift b/cmuxTests/OmnibarAndToolsTests.swift new file mode 100644 index 00000000..6b61a766 --- /dev/null +++ b/cmuxTests/OmnibarAndToolsTests.swift @@ -0,0 +1,857 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class FinderServicePathResolverTests: XCTestCase { + func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() { + let input: [URL] = [ + URL(fileURLWithPath: "/tmp/cmux-services/project", isDirectory: true), + URL(fileURLWithPath: "/tmp/cmux-services/project/README.md", isDirectory: false), + URL(fileURLWithPath: "/tmp/cmux-services/../cmux-services/project", isDirectory: true), + URL(fileURLWithPath: "/tmp/cmux-services/other", isDirectory: true), + ] + + let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input) + XCTAssertEqual( + directories, + [ + "/tmp/cmux-services/project", + "/tmp/cmux-services/other", + ] + ) + } + + func testOrderedUniqueDirectoriesPreservesFirstSeenOrder() { + let input: [URL] = [ + URL(fileURLWithPath: "/tmp/cmux-services/b", isDirectory: true), + URL(fileURLWithPath: "/tmp/cmux-services/a/file.txt", isDirectory: false), + URL(fileURLWithPath: "/tmp/cmux-services/a", isDirectory: true), + URL(fileURLWithPath: "/tmp/cmux-services/b/file.txt", isDirectory: false), + ] + + let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input) + XCTAssertEqual( + directories, + [ + "/tmp/cmux-services/b", + "/tmp/cmux-services/a", + ] + ) + } +} + + +final class VSCodeServeWebURLBuilderTests: XCTestCase { + func testExtractWebUIURLParsesServeWebOutput() { + let output = """ + * + * Visual Studio Code Server + * + Web UI available at http://127.0.0.1:5555?tkn=test-token + """ + + let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output) + XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token") + } + + func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() { + let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")! + + let url = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: baseURL, + directoryPath: "/Users/tester/Projects/cmux" + ) + + let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) + XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token") + XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux") + } + + func testOpenFolderURLReplacesExistingFolderQuery() { + let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")! + + let url = VSCodeServeWebURLBuilder.openFolderURL( + baseWebUIURL: baseURL, + directoryPath: "/Users/tester/New Folder" + ) + + let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) + XCTAssertEqual( + components?.queryItems?.filter { $0.name == "folder" }.count, + 1 + ) + XCTAssertEqual( + components?.queryItems?.first(where: { $0.name == "folder" })?.value, + "/Users/tester/New Folder" + ) + } +} + + +final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase { + func testLaunchConfigurationUsesCodeTunnelBinary() { + let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) + let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel" + + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: appURL, + baseEnvironment: [:], + isExecutableAtPath: { $0 == expectedExecutablePath } + ) + + XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath) + XCTAssertEqual(configuration?.argumentsPrefix, []) + XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1") + } + + func testLaunchConfigurationMapsNodeEnvironmentVariables() { + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), + baseEnvironment: [ + "PATH": "/usr/bin:/bin", + "NODE_OPTIONS": "--max-old-space-size=4096", + "NODE_REPL_EXTERNAL_MODULE": "module-name" + ], + isExecutableAtPath: { _ in true } + ) + + XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") + XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096") + XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name") + XCTAssertNil(configuration?.environment["NODE_OPTIONS"]) + XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"]) + } + + func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() { + let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), + baseEnvironment: [ + "PATH": "/usr/bin:/bin", + "VSCODE_NODE_OPTIONS": "--stale", + "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module" + ], + isExecutableAtPath: { _ in true } + ) + + XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") + XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"]) + XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"]) + } +} + + +final class ServeWebOutputCollectorTests: XCTestCase { + func testWaitForURLReturnsFalseAfterProcessExitSignal() { + let collector = ServeWebOutputCollector() + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + collector.markProcessExited() + } + + let start = Date() + let resolved = collector.waitForURL(timeoutSeconds: 1) + let elapsed = Date().timeIntervalSince(start) + + XCTAssertFalse(resolved) + XCTAssertLessThan(elapsed, 0.5) + } + + func testWaitForURLReturnsTrueWhenURLIsCollected() { + let collector = ServeWebOutputCollector() + let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n" + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + collector.append(Data(urlLine.utf8)) + } + + XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1)) + XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token") + } + + func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() { + let collector = ServeWebOutputCollector() + let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token" + + collector.append(Data(finalChunk.utf8)) + collector.markProcessExited() + + XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) + XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token") + } +} + + +final class VSCodeServeWebControllerTests: XCTestCase { + func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() { + let firstLaunchStarted = expectation(description: "first launch started") + let firstCompletionCalled = expectation(description: "first generation completion called") + let secondCompletionCalled = expectation(description: "second generation completion called") + + let launchGate = DispatchSemaphore(value: 0) + let launchCallLock = NSLock() + var launchCallCount = 0 + + let controller = VSCodeServeWebController.makeForTesting { _, _ in + launchCallLock.lock() + launchCallCount += 1 + let callNumber = launchCallCount + launchCallLock.unlock() + + if callNumber == 1 { + firstLaunchStarted.fulfill() + _ = launchGate.wait(timeout: .now() + 1) + } + return nil + } + + let callbackLock = NSLock() + var firstGenerationCallbacks: [URL?] = [] + var secondGenerationCallbacks: [URL?] = [] + let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) + + controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in + callbackLock.lock() + firstGenerationCallbacks.append(url) + callbackLock.unlock() + firstCompletionCalled.fulfill() + } + + wait(for: [firstLaunchStarted], timeout: 1) + controller.stop() + + controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in + callbackLock.lock() + secondGenerationCallbacks.append(url) + callbackLock.unlock() + secondCompletionCalled.fulfill() + } + + launchGate.signal() + wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2) + + callbackLock.lock() + let firstSnapshot = firstGenerationCallbacks + let secondSnapshot = secondGenerationCallbacks + callbackLock.unlock() + + launchCallLock.lock() + let launchCalls = launchCallCount + launchCallLock.unlock() + + XCTAssertEqual(firstSnapshot.count, 1) + if firstSnapshot.count == 1 { + XCTAssertNil(firstSnapshot[0]) + } + XCTAssertEqual(secondSnapshot.count, 1) + if secondSnapshot.count == 1 { + XCTAssertNil(secondSnapshot[0]) + } + XCTAssertEqual(launchCalls, 2) + } + + func testStopRemovesOrphanedConnectionTokenFiles() throws { + let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tokenFileURL) } + try Data("token".utf8).write(to: tokenFileURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) + + let controller = VSCodeServeWebController.makeForTesting { _, _ in + XCTFail("Expected no launch") + return nil + } + controller.trackConnectionTokenFileForTesting(tokenFileURL) + + controller.stop() + + XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path)) + } +} + + +final class OmnibarStateMachineTests: XCTestCase { + func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws { + var state = OmnibarState() + + var effects = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + XCTAssertTrue(state.isFocused) + XCTAssertEqual(state.buffer, "https://example.com/") + XCTAssertFalse(state.isUserEditing) + XCTAssertTrue(effects.shouldSelectAll) + + effects = omnibarReduce(state: &state, event: .bufferChanged("exam")) + XCTAssertTrue(state.isUserEditing) + XCTAssertEqual(state.buffer, "exam") + XCTAssertTrue(effects.shouldRefreshSuggestions) + + // Simulate an open popup. + effects = omnibarReduce( + state: &state, + event: .suggestionsUpdated([.search(engineName: "Google", query: "exam")]) + ) + XCTAssertEqual(state.suggestions.count, 1) + XCTAssertFalse(effects.shouldSelectAll) + + // First escape: revert + close popup + select-all. + effects = omnibarReduce(state: &state, event: .escape) + XCTAssertEqual(state.buffer, "https://example.com/") + XCTAssertFalse(state.isUserEditing) + XCTAssertTrue(state.suggestions.isEmpty) + XCTAssertTrue(effects.shouldSelectAll) + XCTAssertFalse(effects.shouldBlurToWebView) + + // Second escape: blur (since we're not editing and popup is closed). + effects = omnibarReduce(state: &state, event: .escape) + XCTAssertTrue(effects.shouldBlurToWebView) + } + + func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("hello")) + XCTAssertTrue(state.isUserEditing) + + _ = omnibarReduce(state: &state, event: .panelURLChanged(currentURLString: "https://b.test/")) + XCTAssertEqual(state.currentURLString, "https://b.test/") + XCTAssertEqual(state.buffer, "hello") + XCTAssertTrue(state.isUserEditing) + + let effects = omnibarReduce(state: &state, event: .escape) + XCTAssertEqual(state.buffer, "https://b.test/") + XCTAssertTrue(effects.shouldSelectAll) + } + + func testFocusLostRevertsUnlessSuppressed() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("typed")) + XCTAssertEqual(state.buffer, "typed") + + _ = omnibarReduce(state: &state, event: .focusLostPreserveBuffer(currentURLString: "https://example.com/")) + XCTAssertEqual(state.buffer, "typed") + + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("typed2")) + _ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/")) + XCTAssertEqual(state.buffer, "https://example.com/") + } + + func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("go")) + + let base: [OmnibarSuggestion] = [ + .search(engineName: "Google", query: "go"), + .remoteSearchSuggestion("go tutorial"), + .remoteSearchSuggestion("go json"), + ] + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(base)) + XCTAssertEqual(state.selectedSuggestionIndex, 0) + + _ = omnibarReduce(state: &state, event: .moveSelection(delta: 2)) + XCTAssertEqual(state.selectedSuggestionIndex, 2) + + // Simulate remote merge update for the same query while popup remains open. + let merged: [OmnibarSuggestion] = [ + .search(engineName: "Google", query: "go"), + .remoteSearchSuggestion("go tutorial"), + .remoteSearchSuggestion("go json"), + .remoteSearchSuggestion("go fmt"), + ] + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged)) + XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open") + } + + func testSuggestionsReopenResetsSelectionToFirstRow() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("go")) + + let rows: [OmnibarSuggestion] = [ + .search(engineName: "Google", query: "go"), + .remoteSearchSuggestion("go tutorial"), + ] + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) + _ = omnibarReduce(state: &state, event: .moveSelection(delta: 1)) + XCTAssertEqual(state.selectedSuggestionIndex, 1) + + _ = omnibarReduce(state: &state, event: .suggestionsUpdated([])) + XCTAssertEqual(state.selectedSuggestionIndex, 0) + + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) + XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row") + } + + func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws { + var state = OmnibarState() + _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/")) + _ = omnibarReduce(state: &state, event: .bufferChanged("gm")) + + let rows: [OmnibarSuggestion] = [ + .search(engineName: "Google", query: "gm"), + .history(url: "https://google.com/", title: "Google"), + .history(url: "https://gmail.com/", title: "Gmail"), + ] + _ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows)) + XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.") + XCTAssertEqual(state.selectedSuggestionID, rows[2].id) + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex])) + XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/") + } +} + + +final class OmnibarRemoteSuggestionMergeTests: XCTestCase { + func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() { + let now = Date() + let entries: [BrowserHistoryStore.Entry] = [ + BrowserHistoryStore.Entry( + id: UUID(), + url: "https://go.dev/", + title: "The Go Programming Language", + lastVisited: now, + visitCount: 10 + ), + ] + + let merged = buildOmnibarSuggestions( + query: "go", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["go tutorial", "go.dev", "go json"], + resolvedURL: nil, + limit: 8 + ) + + let completions = merged.compactMap { $0.completion } + XCTAssertGreaterThanOrEqual(completions.count, 5) + XCTAssertEqual(completions[0], "https://go.dev/") + XCTAssertEqual(completions[1], "go") + + let remoteCompletions = Array(completions.dropFirst(2)) + XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"])) + XCTAssertEqual(remoteCompletions.count, 3) + } + + func testStaleRemoteSuggestionsKeptForNearbyEdits() { + let stale = staleOmnibarRemoteSuggestionsForDisplay( + query: "go t", + previousRemoteQuery: "go", + previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"], + limit: 8 + ) + + XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"]) + } + + func testStaleRemoteSuggestionsTrimAndRespectLimit() { + let stale = staleOmnibarRemoteSuggestionsForDisplay( + query: "gooo", + previousRemoteQuery: "goo", + previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"], + limit: 2 + ) + + XCTAssertEqual(stale, ["go tutorial", "go json"]) + } + + func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() { + let stale = staleOmnibarRemoteSuggestionsForDisplay( + query: "python", + previousRemoteQuery: "go", + previousRemoteSuggestions: ["go tutorial", "go json"], + limit: 8 + ) + + XCTAssertTrue(stale.isEmpty) + } +} + + +final class OmnibarSuggestionRankingTests: XCTestCase { + private var fixedNow: Date { + Date(timeIntervalSinceReferenceDate: 10_000_000) + } + + func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://news.ycombinator.com/", + title: "News.YC", + lastVisited: fixedNow, + visitCount: 12, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://www.google.com/", + title: "Google", + lastVisited: fixedNow - 200, + visitCount: 8, + typedCount: 2, + lastTypedAt: fixedNow - 200 + ), + ] + + let results = buildOmnibarSuggestions( + query: "n", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["search google for n", "news"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/") + XCTAssertNotEqual(results.map(\.completion).first, "n") + XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false) + } + + func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://google.com/", + title: "Google", + lastVisited: fixedNow, + visitCount: 4, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://gmail.com/", + title: "Gmail", + lastVisited: fixedNow, + visitCount: 10, + typedCount: 2, + lastTypedAt: fixedNow + ), + ] + + let results = buildOmnibarSuggestions( + query: "gm", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["gmail", "gmail.com", "google mail"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + XCTAssertEqual(results.first?.completion, "https://gmail.com/") + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) + + let inlineCompletion = omnibarInlineCompletionForDisplay( + typedText: "gm", + suggestions: results, + isFocused: true, + selectionRange: NSRange(location: 2, length: 0), + hasMarkedText: false + ) + XCTAssertNotNil(inlineCompletion) + } + + func testAutocompletionCandidateWinsOverRemoteAndSearchRowsForTwoLetterQuery() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://google.com/", + title: "Google", + lastVisited: fixedNow, + visitCount: 4, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://gmail.com/", + title: "Gmail", + lastVisited: fixedNow, + visitCount: 10, + typedCount: 2, + lastTypedAt: fixedNow + ), + ] + + let results = buildOmnibarSuggestions( + query: "gm", + engineName: "Google", + historyEntries: entries, + openTabMatches: [ + .init( + tabId: UUID(), + panelId: UUID(), + url: "https://gmail.com/", + title: "Gmail", + isKnownOpenTab: true + ), + ], + remoteQueries: ["Search google for gm", "gmail", "gmail.com", "Google mail"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) + XCTAssertEqual(results.first?.completion, "https://gmail.com/") + } + + func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://google.com/", + title: "Google", + lastVisited: fixedNow, + visitCount: 4, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://gmail.com/", + title: "Gmail", + lastVisited: fixedNow, + visitCount: 10, + typedCount: 2, + lastTypedAt: fixedNow + ), + ] + + let results = buildOmnibarSuggestions( + query: "gm", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["Search google for gm", "gmail", "gmail.com"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + var state = OmnibarState() + let _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "")) + let _ = omnibarReduce(state: &state, event: .bufferChanged("gm")) + let _ = omnibarReduce(state: &state, event: .suggestionsUpdated(results)) + + XCTAssertEqual(state.selectedSuggestionIndex, 0) + XCTAssertEqual(state.selectedSuggestionID, results[0].id) + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[0])) + } + + func testTwoCharQueryWithRemoteSuggestionsStillPromotesAutocompletionMatch() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://news.ycombinator.com/", + title: "News.YC", + lastVisited: fixedNow, + visitCount: 12, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://www.google.com/", + title: "Google", + lastVisited: fixedNow - 200, + visitCount: 8, + typedCount: 2, + lastTypedAt: fixedNow - 200 + ), + ] + + let results = buildOmnibarSuggestions( + query: "ne", + engineName: "Google", + historyEntries: entries, + openTabMatches: [], + remoteQueries: ["netflix", "new york times", "newegg"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + // The autocompletable history entry (news.ycombinator.com) should be first despite remote results. + XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/") + XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "ne", suggestion: $0) } ?? false) + + // Remote suggestions should still appear in the results (two-char queries include them). + let remoteCompletions = results.filter { + if case .remote = $0.kind { return true } + return false + }.map(\.completion) + XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions to be present for two-char query") + } + + func testGmQueryWithRemoteSuggestionsAndOpenTabPromotesAutocompletionMatch() { + let entries: [BrowserHistoryStore.Entry] = [ + .init( + id: UUID(), + url: "https://google.com/", + title: "Google", + lastVisited: fixedNow, + visitCount: 4, + typedCount: 1, + lastTypedAt: fixedNow + ), + .init( + id: UUID(), + url: "https://gmail.com/", + title: "Gmail", + lastVisited: fixedNow, + visitCount: 10, + typedCount: 2, + lastTypedAt: fixedNow + ), + ] + + let results = buildOmnibarSuggestions( + query: "gm", + engineName: "Google", + historyEntries: entries, + openTabMatches: [ + .init( + tabId: UUID(), + panelId: UUID(), + url: "https://google.com/maps", + title: "Google Maps", + isKnownOpenTab: true + ), + ], + remoteQueries: ["gmail login", "gm stock price", "gmail.com"], + resolvedURL: nil, + limit: 8, + now: fixedNow + ) + + // Gmail should be first (autocompletable + typed history). + XCTAssertEqual(results.first?.completion, "https://gmail.com/") + XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0])) + + // Verify remote suggestions are present alongside history/tab matches. + let remoteCompletions = results.filter { + if case .remote = $0.kind { return true } + return false + }.map(\.completion) + XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions in results") + let hasSearch = results.contains { + if case .search = $0.kind { return true } + return false + } + XCTAssertTrue(hasSearch, "Expected search row in results") + } + + func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() { + let row = OmnibarSuggestion.history( + url: "https://www.example.com/path?q=1", + title: "Example Domain" + ) + XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1") + XCTAssertFalse(row.listText.contains("\n")) + } + + func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let published = omnibarPublishedBufferTextForFieldChange( + fieldValue: inline.displayText, + inlineCompletion: inline, + selectionRange: inline.suffixRange, + hasMarkedText: false + ) + + XCTAssertEqual(published, "l") + } + + func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let published = omnibarPublishedBufferTextForFieldChange( + fieldValue: "la", + inlineCompletion: inline, + selectionRange: NSRange(location: 2, length: 0), + hasMarkedText: false + ) + + XCTAssertEqual(published, "la") + } + + func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() { + let staleInline = OmnibarInlineCompletion( + typedText: "g", + displayText: "github.com", + acceptedText: "https://github.com/" + ) + + let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: "l", + inlineCompletion: staleInline + ) + + XCTAssertNil(active) + } + + func testInlineCompletionRenderKeepsMatchingTypedPrefix() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: "l", + inlineCompletion: inline + ) + + XCTAssertEqual(active, inline) + } + + func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() { + // History entry: visited google.com/search?q=localhost:3000 with title + // "localhost:3000 - Google Search". Typing "l" should NOT inline-complete + // to "google.com/..." because that replaces the typed "l" with "g". + let suggestions: [OmnibarSuggestion] = [ + .history( + url: "https://www.google.com/search?q=localhost:3000", + title: "localhost:3000 - Google Search" + ), + ] + + let result = omnibarInlineCompletionForDisplay( + typedText: "l", + suggestions: suggestions, + isFocused: true, + selectionRange: NSRange(location: 1, length: 0), + hasMarkedText: false + ) + + XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix") + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 7d04db1d..8c00c0c1 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -86,6 +86,28 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testSaveSkipsRewritingIdenticalSnapshotData() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + let firstFileNumber = try fileNumber(for: snapshotURL) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + let secondFileNumber = try fileNumber(for: snapshotURL) + + XCTAssertEqual( + secondFileNumber, + firstFileNumber, + "Saving identical session data should not replace the snapshot file" + ) + } + func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws { var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) snapshot.windows[0].tabManager.workspaces[0].customColor = nil @@ -780,6 +802,11 @@ final class SessionPersistenceTests: XCTestCase { windows: [window] ) } + + private func fileNumber(for fileURL: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + return try XCTUnwrap(attributes[.systemFileNumber] as? Int) + } } final class SocketListenerAcceptPolicyTests: XCTestCase { diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift new file mode 100644 index 00000000..cc5b0c01 --- /dev/null +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -0,0 +1,965 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SplitShortcutTransientFocusGuardTests: XCTestCase { + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: false + ) + ) + } + + func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: false, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } +} + + +final class FullScreenShortcutTests: XCTestCase { + func testMatchesCommandControlF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "f", + keyCode: 3 + ) + ) + } + + func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in "u" } + ) + ) + } + + func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, modifierFlags in + modifierFlags.contains(.command) ? "f" : "u" + } + ) + ) + } + + func testMatchesCommandControlFWhenCharsAreControlSequence() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "\u{06}", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "u", + keyCode: 3 + ) + ) + } + + func testIgnoresCapsLockForCommandControlF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .capsLock], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsWhenControlIsMissing() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsAdditionalModifiers() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .shift], + chars: "f", + keyCode: 3 + ) + ) + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control, .option], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsWhenCommandIsMissing() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.control], + chars: "f", + keyCode: 3 + ) + ) + } + + func testRejectsNonFKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "r", + keyCode: 15 + ) + ) + } +} + + +final class CommandPaletteKeyboardNavigationTests: XCTestCase { + func testArrowKeysMoveSelectionWithoutModifiers() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 125 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 126 + ), + -1 + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.shift], + chars: "", + keyCode: 125 + ) + ) + } + + func testControlLetterNavigationSupportsPrintableAndControlChars() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "n", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0e}", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "p", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{10}", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "j", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0a}", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "k", + keyCode: 40 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0b}", + keyCode: 40 + ), + -1 + ) + } + + func testIgnoresUnsupportedModifiersAndKeys() { + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control, .shift], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "x", + keyCode: 7 + ) + ) + } +} + + +final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { + func testDoesNotConsumeWhenPaletteIsNotVisible() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: false, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testConsumesAppCommandShortcutsWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "t", + keyCode: 17 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: ",", + keyCode: 43 + ) + ) + } + + func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "v", + keyCode: 9 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "z", + keyCode: 6 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: "z", + keyCode: 6 + ) + ) + } + + func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 123 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 51 + ) + ) + } + + func testConsumesEscapeWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [], + chars: "", + keyCode: 53 + ) + ) + } +} + + +final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { + func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { + let panelId = UUID() + XCTAssertTrue( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() { + let panelId = UUID() + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: false, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() { + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: UUID(), + focusedPanelId: UUID() + ) + ) + } +} + + +final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { + private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" + + private func makeDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + func testDefaultsToSelectAllWhenUnset() { + let defaults = makeDefaults() + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsFalseWhenStoredFalse() { + let defaults = makeDefaults() + defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsTrueWhenStoredTrue() { + let defaults = makeDefaults() + defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } +} + + +final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.top) + } + + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 19, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.bottom) + } + + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 6, + resultCount: 20 + ) + XCTAssertNil(anchor) + } + + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 0 + ) + XCTAssertNil(anchor) + } +} + + +final class ShortcutHintModifierPolicyTests: XCTestCase { + func testShortcutHintRequiresEnabledCommandOnlyModifier() { + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) + } + } + + func testCommandHintCanBeDisabledInSettings() { + withDefaultsSuite { defaults in + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + + func testCommandHintDefaultsToEnabledWhenSettingMissing() { + withDefaultsSuite { defaults in + defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + + func testShortcutHintUsesIntentionalHoldDelay() { + XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) + } + + func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { + XCTAssertTrue( + ShortcutHintModifierPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 7, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: false, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + } + + func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7, + defaults: defaults + ) + ) + + XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + } + } + + private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { + let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + body(defaults) + defaults.removePersistentDomain(forName: suiteName) + } +} + + +final class ShortcutHintDebugSettingsTests: XCTestCase { + func testClampKeepsValuesWithinSupportedRange() { + XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0) + XCTAssertEqual(ShortcutHintDebugSettings.clamped(4.0), 4.0) + XCTAssertEqual(ShortcutHintDebugSettings.clamped(-100.0), ShortcutHintDebugSettings.offsetRange.lowerBound) + XCTAssertEqual(ShortcutHintDebugSettings.clamped(100.0), ShortcutHintDebugSettings.offsetRange.upperBound) + } + + func testDefaultOffsetsMatchCurrentBadgePlacements() { + XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintX, 0.0) + XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintY, 0.0) + XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintX, 4.0) + XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintY, 0.0) + XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0) + XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0) + XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints) + XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold) + } + + func testShowHintsOnCommandHoldSettingRespectsStoredValue() { + let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) + } + + func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { + let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) + + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool, + ShortcutHintDebugSettings.defaultAlwaysShowHints + ) + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, + ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + ) + } +} + + +final class DevBuildBannerDebugSettingsTests: XCTestCase { + func testShowSidebarBannerDefaultsToVisible() { + let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } + + func testShowSidebarBannerRespectsStoredValue() { + let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + + defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } +} + + +final class ShortcutHintLanePlannerTests: XCTestCase { + func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { + let intervals: [ClosedRange] = [0...20, 28...40, 48...64] + XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0]) + } + + func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() { + let intervals: [ClosedRange] = [0...20, 18...34, 22...38, 40...56] + XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 1, 2, 0]) + } +} + + +final class ShortcutHintHorizontalPlannerTests: XCTestCase { + func testAssignRightEdgesResolvesOverlapWithMinimumSpacing() { + let intervals: [ClosedRange] = [0...20, 18...34, 30...46] + let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 6) + + XCTAssertEqual(rightEdges.count, intervals.count) + + let adjustedIntervals = zip(intervals, rightEdges).map { interval, rightEdge in + let width = interval.upperBound - interval.lowerBound + return (rightEdge - width)...rightEdge + } + + XCTAssertGreaterThanOrEqual(adjustedIntervals[1].lowerBound - adjustedIntervals[0].upperBound, 6) + XCTAssertGreaterThanOrEqual(adjustedIntervals[2].lowerBound - adjustedIntervals[1].upperBound, 6) + } + + func testAssignRightEdgesKeepsAlreadySeparatedIntervalsInPlace() { + let intervals: [ClosedRange] = [0...12, 20...32, 40...52] + let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 4) + XCTAssertEqual(rightEdges, [12, 32, 52]) + } +} + + +final class LastSurfaceCloseShortcutSettingsTests: XCTestCase { + func testDefaultClosesWorkspace() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } + + func testStoredTrueClosesWorkspace() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key) + XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } + + func testStoredFalseKeepsWorkspaceOpen() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key) + XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } +} + + +final class AppearanceSettingsTests: XCTestCase { + func testResolvedModeDefaultsToSystemWhenUnset() { + let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey) + + let resolved = AppearanceSettings.resolvedMode(defaults: defaults) + XCTAssertEqual(resolved, .system) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) + } +} + + +final class QuitWarningSettingsTests: XCTestCase { + func testDefaultWarnBeforeQuitIsEnabledWhenUnset() { + let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey) + + XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) + } + + func testStoredPreferenceOverridesDefault() { + let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey) + XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey) + XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) + } +} + + +final class UpdateChannelSettingsTests: XCTestCase { + func testResolvedFeedFallsBackWhenInfoFeedMissing() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) + XCTAssertFalse(resolved.isNightly) + XCTAssertTrue(resolved.usedFallback) + } + + func testResolvedFeedFallsBackWhenInfoFeedEmpty() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "") + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) + XCTAssertFalse(resolved.isNightly) + XCTAssertTrue(resolved.usedFallback) + } + + func testResolvedFeedUsesInfoFeedForStableChannel() { + let infoFeed = "https://example.com/custom/appcast.xml" + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) + XCTAssertEqual(resolved.url, infoFeed) + XCTAssertFalse(resolved.isNightly) + XCTAssertFalse(resolved.usedFallback) + } + + func testResolvedFeedDetectsNightlyFromInfoFeedURL() { + let resolved = UpdateFeedResolver.resolvedFeedURLString( + infoFeedURL: "https://example.com/nightly/appcast.xml" + ) + XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml") + XCTAssertTrue(resolved.isNightly) + XCTAssertFalse(resolved.usedFallback) + } +} + + +final class UpdateSettingsTests: XCTestCase { + func testApplyEnablesAutomaticChecksAndDailySchedule() { + let defaults = makeDefaults() + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) + } + + func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { + let defaults = makeDefaults() + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) + + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + UpdateSettings.apply(to: defaults) + + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + + +@MainActor +final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { + func testShouldPromoteWhenBecomingVisible() { + XCTAssertTrue( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: false, + isVisible: true + ) + ) + } + + func testShouldNotPromoteWhenAlreadyVisible() { + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: true, + isVisible: true + ) + ) + } + + func testShouldNotPromoteWhenHidden() { + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: true, + isVisible: false + ) + ) + XCTAssertFalse( + CommandPaletteOverlayPromotionPolicy.shouldPromote( + previouslyVisible: false, + isVisible: false + ) + ) + } +} diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift new file mode 100644 index 00000000..e9301bb6 --- /dev/null +++ b/cmuxTests/SidebarOrderingTests.swift @@ -0,0 +1,941 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SidebarActiveForegroundColorTests: XCTestCase { + func testLightAppearanceUsesBlackWithRequestedOpacity() { + guard let lightAppearance = NSAppearance(named: .aqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.8, + appAppearance: lightAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } + + func testDarkAppearanceUsesWhiteWithRequestedOpacity() { + guard let darkAppearance = NSAppearance(named: .darkAqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.65, + appAppearance: darkAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + + +final class SidebarBranchLayoutSettingsTests: XCTestCase { + func testDefaultUsesVerticalLayout() { + let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } + + func testStoredPreferenceOverridesDefault() { + let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: SidebarBranchLayoutSettings.key) + XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + + defaults.set(true, forKey: SidebarBranchLayoutSettings.key) + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } +} + + +final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { + func testDefaultStyleWhenUnset() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } + + func testStoredStyleParsesAndInvalidFallsBack() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } +} + + +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testClipboardTextSingleEntryUsesStructuredEntryFields() { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to bootstrap daemon" + ) + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), + "SSH error (devbox:22): failed to bootstrap daemon" + ) + } +} + + +final class SidebarBranchOrderingTests: XCTestCase { + + func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [first, second, third], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true) + ], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + branches, + [ + SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true), + SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false) + ] + ) + } + + func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() { + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [], + panelBranches: [:], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true) + ) + + XCTAssertEqual( + branches, + [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + let fifth = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second, third, fourth, fifth], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true), + fourth: SidebarGitBranchState(branch: "main", isDirty: false) + ], + panelDirectories: [ + first: "/repo/a", + second: "/repo/b", + third: "/repo/a", + fourth: "/repo/d", + fifth: "/repo/e" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() { + let first = UUID() + let second = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second], + panelBranches: [:], + panelDirectories: [ + first: "/repo/one", + second: "/repo/two" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() { + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [], + panelBranches: [:], + panelDirectories: [:], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false) + ) + + XCTAssertEqual( + rows, + [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] + ) + } + + func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second, third, fourth], + panelPullRequests: [ + first: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .open + ), + second: pullRequestState( + number: 18, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", + status: .open + ), + third: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .merged + ), + fourth: pullRequestState( + number: 92, + label: "PR", + url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", + status: .closed + ) + ], + fallbackPullRequest: pullRequestState( + number: 1, + label: "PR", + url: "https://example.invalid/fallback/1", + status: .open + ) + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#337", "MR#18", "PR#92"] + ) + XCTAssertEqual( + pullRequests.map(\.status), + [.merged, .open, .closed] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#42", "MR#42"] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/other-repo/pull/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map(\.url.absoluteString), + [ + "https://github.com/manaflow-ai/cmux/pull/42", + "https://github.com/manaflow-ai/other-repo/pull/42" + ] + ) + } + + func testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open, + checks: .pass + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual(pullRequests.count, 1) + XCTAssertEqual(pullRequests.first?.checks, .pass) + } + + @MainActor + func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelPullRequest( + panelId: panelId, + number: 42, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, + status: .open, + checks: .pass + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 42, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, + status: .open + ) + + XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass) + XCTAssertEqual(workspace.pullRequest?.checks, .pass) + } + + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { + let fallback = pullRequestState( + number: 11, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/11", + status: .open + ) + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [], + panelPullRequests: [:], + fallbackPullRequest: fallback + ) + + XCTAssertEqual(pullRequests, [fallback]) + } + + @MainActor + func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open + ) + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + + XCTAssertNil(workspace.pullRequest) + XCTAssertNil(workspace.panelPullRequests[panelId]) + XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) + } + + @MainActor + func testSidebarPullRequestsHideBranchMismatches() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open, + branch: "feature/sidebar-pr" + ) + + XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status, + branch: branch, + checks: checks + ) + } +} + + +final class SidebarDropPlannerTests: XCTestCase { + func testNoIndicatorForNoOpEdges() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: first, + targetTabId: first, + tabIds: tabIds, + pinnedTabIds: [] + ) + ) + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: nil, + tabIds: tabIds, + pinnedTabIds: [] + ) + ) + } + + func testNoIndicatorWhenOnlyOneTabExists() { + let only = UUID() + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: only, + targetTabId: nil, + tabIds: [only], + pinnedTabIds: [] + ) + ) + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: only, + targetTabId: only, + tabIds: [only], + pinnedTabIds: [] + ) + ) + } + + func testIndicatorAppearsForRealMoveToEnd() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: second, + targetTabId: nil, + tabIds: tabIds, + pinnedTabIds: [] + ) + XCTAssertEqual(indicator?.tabId, nil) + XCTAssertEqual(indicator?.edge, .bottom) + } + + func testTargetIndexForMoveToEndFromMiddle() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + let index = SidebarDropPlanner.targetIndex( + draggedTabId: second, + targetTabId: nil, + indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), + tabIds: tabIds, + pinnedTabIds: [] + ) + XCTAssertEqual(index, 2) + } + + func testNoIndicatorForSelfDropInMiddle() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: second, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [] + ) + ) + } + + func testPointerEdgeTopCanSuppressNoOpWhenDraggingFirstOverSecond() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: first, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [], + pointerY: 2, + targetHeight: 40 + ) + ) + } + + func testPointerEdgeBottomAllowsMoveWhenDraggingFirstOverSecond() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: first, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + XCTAssertEqual(indicator?.tabId, third) + XCTAssertEqual(indicator?.edge, .top) + XCTAssertEqual( + SidebarDropPlanner.targetIndex( + draggedTabId: first, + targetTabId: second, + indicator: indicator, + tabIds: tabIds, + pinnedTabIds: [] + ), + 1 + ) + } + + func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + let fromBottomOfFirst = SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: first, + tabIds: tabIds, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + let fromTopOfSecond = SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [], + pointerY: 2, + targetHeight: 40 + ) + + XCTAssertEqual(fromBottomOfFirst?.tabId, second) + XCTAssertEqual(fromBottomOfFirst?.edge, .top) + XCTAssertEqual(fromTopOfSecond?.tabId, second) + XCTAssertEqual(fromTopOfSecond?.edge, .top) + } + + func testPointerEdgeBottomSuppressesNoOpWhenDraggingLastOverSecond() { + let first = UUID() + let second = UUID() + let third = UUID() + let tabIds = [first, second, third] + + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + ) + } + + func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + tabIds: tabIds, + pinnedTabIds: pinnedIds, + pointerY: 2, + targetHeight: 40 + ) + + XCTAssertEqual(indicator?.tabId, unpinnedA) + XCTAssertEqual(indicator?.edge, .top) + } + + func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), + tabIds: tabIds, + pinnedTabIds: pinnedIds + ) + + XCTAssertEqual(targetIndex, 2) + } + +} + + +final class SidebarDragAutoScrollPlannerTests: XCTestCase { + func testAutoScrollPlanTriggersNearTopAndBottomOnly() { + let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12) + XCTAssertEqual(topPlan?.direction, .up) + XCTAssertNotNil(topPlan) + + let bottomPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 96, distanceToBottom: 4, edgeInset: 44, minStep: 2, maxStep: 12) + XCTAssertEqual(bottomPlan?.direction, .down) + XCTAssertNotNil(bottomPlan) + + XCTAssertNil( + SidebarDragAutoScrollPlanner.plan(distanceToTop: 60, distanceToBottom: 60, edgeInset: 44, minStep: 2, maxStep: 12) + ) + } + + func testAutoScrollPlanSpeedsUpCloserToEdge() { + let nearTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 1, distanceToBottom: 99, edgeInset: 44, minStep: 2, maxStep: 12) + let midTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 22, distanceToBottom: 78, edgeInset: 44, minStep: 2, maxStep: 12) + + XCTAssertNotNil(nearTop) + XCTAssertNotNil(midTop) + XCTAssertGreaterThan(nearTop?.pointsPerTick ?? 0, midTop?.pointsPerTick ?? 0) + } + + func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() { + let aboveTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: -500, distanceToBottom: 600, edgeInset: 44, minStep: 2, maxStep: 12) + XCTAssertEqual(aboveTop?.direction, .up) + XCTAssertEqual(aboveTop?.pointsPerTick, 12) + + let belowBottom = SidebarDragAutoScrollPlanner.plan(distanceToTop: 600, distanceToBottom: -500, edgeInset: 44, minStep: 2, maxStep: 12) + XCTAssertEqual(belowBottom?.direction, .down) + XCTAssertEqual(belowBottom?.pointsPerTick, 12) + } +} + + +final class TerminalControllerSidebarDedupeTests: XCTestCase { + func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() { + let current = SidebarStatusEntry( + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff", + timestamp: Date(timeIntervalSince1970: 123) + ) + XCTAssertFalse( + TerminalController.shouldReplaceStatusEntry( + current: current, + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff", + url: nil, + priority: 0, + format: .plain + ) + ) + } + + func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() { + let current = SidebarStatusEntry( + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff", + timestamp: Date(timeIntervalSince1970: 123) + ) + XCTAssertTrue( + TerminalController.shouldReplaceStatusEntry( + current: current, + key: "agent", + value: "running", + icon: "bolt", + color: "#ffffff", + url: nil, + priority: 0, + format: .plain + ) + ) + } + + func testShouldReplaceProgressReturnsFalseForUnchangedPayload() { + XCTAssertFalse( + TerminalController.shouldReplaceProgress( + current: SidebarProgressState(value: 0.42, label: "indexing"), + value: 0.42, + label: "indexing" + ) + ) + } + + func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() { + XCTAssertFalse( + TerminalController.shouldReplaceGitBranch( + current: SidebarGitBranchState(branch: "main", isDirty: true), + branch: "main", + isDirty: true + ) + ) + } + + func testShouldReplacePortsIgnoresOrderAndDuplicates() { + XCTAssertFalse( + TerminalController.shouldReplacePorts( + current: [9229, 3000], + next: [3000, 9229, 3000] + ) + ) + XCTAssertTrue( + TerminalController.shouldReplacePorts( + current: [9229, 3000], + next: [3000] + ) + ) + } + + func testExplicitSocketScopeParsesValidUUIDTabAndPanel() { + let workspaceId = UUID() + let panelId = UUID() + let scope = TerminalController.explicitSocketScope( + options: [ + "tab": workspaceId.uuidString, + "panel": panelId.uuidString + ] + ) + XCTAssertEqual(scope?.workspaceId, workspaceId) + XCTAssertEqual(scope?.panelId, panelId) + } + + func testExplicitSocketScopeAcceptsSurfaceAlias() { + let workspaceId = UUID() + let panelId = UUID() + let scope = TerminalController.explicitSocketScope( + options: [ + "tab": workspaceId.uuidString, + "surface": panelId.uuidString + ] + ) + XCTAssertEqual(scope?.workspaceId, workspaceId) + XCTAssertEqual(scope?.panelId, panelId) + } + + func testExplicitSocketScopeRejectsMissingOrInvalidValues() { + XCTAssertNil(TerminalController.explicitSocketScope(options: [:])) + XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString])) + XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"])) + } + + func testNormalizeReportedDirectoryTrimsWhitespace() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory(" /Users/cmux/project "), + "/Users/cmux/project" + ) + } + + func testNormalizeReportedDirectoryResolvesFileURL() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"), + "/Users/cmux/project" + ) + } + + func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory(" file://bad host "), + "file://bad host" + ) + } +} diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift new file mode 100644 index 00000000..d6942378 --- /dev/null +++ b/cmuxTests/TabManagerUnitTests.swift @@ -0,0 +1,976 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" + +func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +final class TabManagerChildExitCloseTests: XCTestCase { + func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id]) + XCTAssertEqual( + manager.selectedTabId, + third.id, + "Expected selection to stay at the same index after deleting the selected workspace" + ) + } + + func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id]) + XCTAssertEqual( + manager.selectedTabId, + first.id, + "Expected previous workspace to be selected after closing the last-index workspace" + ) + } + + func testChildExitOnNonLastPanelClosesOnlyPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panel to be created") + return + } + + let panelCountBefore = workspace.panels.count + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertEqual(workspace.panels.count, panelCountBefore - 1) + XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain") + } +} + + +@MainActor +final class TabManagerWorkspaceOwnershipTests: XCTestCase { + func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() { + let manager = TabManager() + _ = manager.addWorkspace() + let initialTabIds = manager.tabs.map(\.id) + let initialSelectedTabId = manager.selectedTabId + + let externalWorkspace = Workspace(title: "External workspace") + let externalPanelCountBefore = externalWorkspace.panels.count + let externalPanelTitlesBefore = externalWorkspace.panelTitles + + manager.closeWorkspace(externalWorkspace) + + XCTAssertEqual(manager.tabs.map(\.id), initialTabIds) + XCTAssertEqual(manager.selectedTabId, initialSelectedTabId) + XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore) + XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore) + } +} + + +@MainActor +final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { + func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return true + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"]) + } + + func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() { + let manager = TabManager() + let second = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspacesWindow.message", + defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1) + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, true) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) + } + + func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + manager.selectWorkspace(second) + manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id]) + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeCurrentWorkspaceWithConfirmation() + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"]) + } +} + + +@MainActor +final class TabManagerCloseCurrentPanelTests: XCTestCase { + func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected selected workspace and focused terminal panel") + return + } + + terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return false + } + + manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state") + XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close") + XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel") + } + + func testRuntimeClosePromptsWhenShellReportsRunningCommand() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected selected workspace and focused terminal panel") + return + } + + terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return false + } + + manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) + + XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation") + XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open") + } + + func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + XCTAssertEqual(secondWorkspace.panels.count, 1) + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() { + let defaults = UserDefaults.standard + let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let originalSetting { + defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + let initialWorkspaceId = workspace.id + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) + XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) + } + + func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + XCTAssertEqual(secondWorkspace.panels.count, 1) + + guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { + XCTFail("Expected bonsplit surface ID for focused panel") + return + } + + secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) + XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() { + let defaults = UserDefaults.standard + let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let originalSetting { + defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { + XCTFail("Expected bonsplit surface ID for focused panel") + return + } + + secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) + XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + let initialWorkspaceId = workspace.id + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(workspace.panels.count, 1) + + XCTAssertTrue(workspace.closePanel(initialPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) + XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) + } + + func testCloseCurrentPanelIgnoresStaleSurfaceId() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + + manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID()) + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id]) + } + + func testCloseCurrentPanelClearsNotificationsForClosedSurface() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: initialPanelId, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) + } +} + + +@MainActor +final class TabManagerNotificationFocusTests: XCTestCase { + func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftPanelId) + XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") + XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") + + XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) + drainMainQueue() + drainMainQueue() + + XCTAssertFalse( + workspace.bonsplitController.isSplitZoomed, + "Expected notification focus to exit split zoom so the target pane becomes visible" + ) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") + } + + func testFocusTabFromNotificationReturnsFalseForMissingPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected selected workspace") + return + } + + XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) + } +} + + +@MainActor +final class TabManagerPendingUnfocusPolicyTests: XCTestCase { + func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { + let tabId = UUID() + + XCTAssertFalse( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: tabId, + selectedTabId: tabId + ) + ) + } + + func testUnfocusesWhenPendingTabIsNotSelected() { + XCTAssertTrue( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: UUID(), + selectedTabId: UUID() + ) + ) + XCTAssertTrue( + TabManager.shouldUnfocusPendingWorkspace( + pendingTabId: UUID(), + selectedTabId: nil + ) + ) + } +} + + +@MainActor +final class TabManagerSurfaceCreationTests: XCTestCase { + func testNewSurfaceFocusesCreatedSurface() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected a selected workspace") + return + } + + let beforePanels = Set(workspace.panels.keys) + manager.newSurface() + let afterPanels = Set(workspace.panels.keys) + + let createdPanels = afterPanels.subtracting(beforePanels) + XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path") + guard let createdPanelId = createdPanels.first else { return } + + XCTAssertEqual( + workspace.focusedPanelId, + createdPanelId, + "Expected newly created surface to be focused" + ) + } + + func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let paneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused workspace and pane") + return + } + + // Add one extra surface so we verify append-to-end rather than first insert behavior. + _ = workspace.newTerminalSurface(inPane: paneId, focus: false) + + guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else { + XCTFail("Expected browser panel to be created") + return + } + + let tabs = workspace.bonsplitController.tabs(inPane: paneId) + guard let lastSurfaceId = tabs.last?.id else { + XCTFail("Expected at least one surface in pane") + return + } + + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end" + ) + XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") + } + + func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { + let manager = TabManager() + guard let initialWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial selected workspace") + return + } + guard let url = URL(string: "https://example.com/pull/123") else { + XCTFail("Expected test URL to be valid") + return + } + + let targetWorkspace = manager.addWorkspace(select: false) + manager.selectWorkspace(initialWorkspace) + let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPanelCount = targetWorkspace.panels.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: targetWorkspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created in target workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") + XCTAssertEqual( + targetWorkspace.bonsplitController.allPaneIds.count, + initialPaneCount + 1, + "Expected split-right browser open to create a new pane" + ) + XCTAssertEqual( + targetWorkspace.panels.count, + initialPanelCount + 1, + "Expected browser panel count to increase by one" + ) + XCTAssertEqual( + targetWorkspace.focusedPanelId, + browserPanelId, + "Expected created browser panel to be focused in target workspace" + ) + XCTAssertTrue( + targetWorkspace.panels[browserPanelId] is BrowserPanel, + "Expected created panel to be a browser panel" + ) + } + + func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), + let url = URL(string: "https://example.com/pull/456") else { + XCTFail("Expected split setup to succeed") + return + } + + let initialPaneCount = workspace.bonsplitController.allPaneIds.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created") + return + } + + XCTAssertEqual( + workspace.bonsplitController.allPaneIds.count, + initialPaneCount, + "Expected split-right browser open to reuse existing panes" + ) + XCTAssertEqual( + workspace.paneId(forPanelId: browserPanelId), + topRightPaneId, + "Expected browser to open in the top-right pane when multiple splits already exist" + ) + + let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabs.last?.id else { + XCTFail("Expected top-right pane to contain tabs") + return + } + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected browser surface to be appended at end in the reused top-right pane" + ) + } +} + + +@MainActor +final class TabManagerEqualizeSplitsTests: XCTestCase { + func testEqualizeSplitsSetsEverySplitDividerToHalf() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else { + XCTFail("Expected nested split setup to succeed") + return + } + + let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout") + + for (index, split) in initialSplits.enumerated() { + guard let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected split ID to be a UUID") + return + } + let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8 + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId), + "Expected to seed divider position for split \(splitId)" + ) + } + + XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed") + + let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertEqual(equalizedSplits.count, initialSplits.count) + for split in equalizedSplits { + XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) + } + } + + private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { + switch node { + case .pane: + return [] + case .split(let split): + return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) + } + } +} + + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } +} + + +@MainActor +final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { + func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() { + let manager = TabManager() + guard let originalWorkspace = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let currentWorkspace = manager.addWorkspace() + manager.closeWorkspace(originalWorkspace) + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id })) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace)) + } + + func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let sourcePanelId = workspace1.focusedPanelId, + let splitBrowserId = manager.newBrowserSplit( + tabId: workspace1.id, + fromPanelId: sourcePanelId, + orientation: .horizontal, + insertFirst: false, + url: URL(string: "https://example.com/collapsed-split") + ) else { + XCTFail("Expected to create browser split") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let preReopenPanelId = workspace1.focusedPanelId, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else { + XCTFail("Expected initial workspace state and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let panelIdsBeforeReopen = Set(workspace1.panels.keys) + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else { + XCTFail("Expected reopened browser panel ID") + return + } + + // Simulate one delayed stale focus callback from the panel that was focused before reopen. + DispatchQueue.main.async { + workspace1.focusPanel(preReopenPanelId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId) + XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel) + } + + func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let preReopenPanelId = workspace.focusedPanelId, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else { + XCTFail("Expected initial workspace state and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let panelIdsBeforeReopen = Set(workspace.panels.keys) + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else { + XCTFail("Expected reopened browser panel ID") + return + } + + // Simulate one delayed stale focus callback from the panel that was focused before reopen. + DispatchQueue.main.async { + workspace.focusPanel(preReopenPanelId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId) + XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel) + } + + private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool { + guard let focusedPanelId = workspace.focusedPanelId else { return false } + return workspace.panels[focusedPanelId] is BrowserPanel + } + + private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set) -> UUID? { + let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds) + guard newPanelIds.count == 1 else { return nil } + return newPanelIds.first + } + + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift new file mode 100644 index 00000000..9faffe0a --- /dev/null +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -0,0 +1,2897 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class GhosttyPasteboardHelperTests: XCTestCase { + func testHTMLOnlyPasteboardExtractsPlainText() { + let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello world

", forType: .html) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testImageHTMLClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".png")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testImageHTMLClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello

", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.blue.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testJPEGClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.green.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let jpegData = try XCTUnwrap( + bitmap.representation( + using: .jpeg, + properties: [.compressionFactor: 1.0] + ) + ) + pasteboard.setData( + jpegData, + forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier) + ) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".jpeg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.orange.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".tiff")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) + pasteboard.clearContents() + + let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8)) + wrapper.preferredFilename = "note.txt" + + let attachment = NSTextAttachment(fileWrapper: wrapper) + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testRTFDClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.purple.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + + let attributed = NSMutableAttributedString(string: "Hello ") + attributed.append(NSAttributedString(attachment: attachment)) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } +} + + +final class TerminalKeyboardCopyModeActionTests: XCTestCase { + func testCopyModeBypassAllowsOnlyCommandShortcuts() { + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift])) + XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift])) + XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control])) + } + + func testJKWithoutSelectionScrollByLine() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: false + ), + .scrollLines(-1) + ) + } + + func testCapsLockDoesNotBlockLetterMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [.capsLock], + hasSelection: false + ), + .scrollLines(1) + ) + } + + func testJKWithSelectionAdjustSelection() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 38, + charactersIgnoringModifiers: "j", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.down) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 40, + charactersIgnoringModifiers: "k", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.up) + ) + } + + func testControlPagingSupportsPrintableAndControlCharacters() { + // Ctrl+U = half-page up (vim standard). + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{15}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollHalfPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{04}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{02}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollPage(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{06}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.pageDown) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{19}", + modifierFlags: [.control], + hasSelection: false + ), + .scrollLines(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 0, + charactersIgnoringModifiers: "\u{05}", + modifierFlags: [.control], + hasSelection: true + ), + .adjustSelection(.down) + ) + } + + func testVGYMapping() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [], + hasSelection: true + ), + .clearSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 16, + charactersIgnoringModifiers: "y", + modifierFlags: [], + hasSelection: true + ), + .copyAndExit + ) + } + + func testGAndShiftGMapping() { + // Bare "g" is a prefix key (gg), not an immediate action. + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 5, + charactersIgnoringModifiers: "g", + modifierFlags: [.shift], + hasSelection: false + ), + .scrollToBottom + ) + } + + func testLineBoundaryPromptAndSearchMappings() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 29, + charactersIgnoringModifiers: "0", + modifierFlags: [], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 20, + charactersIgnoringModifiers: "^", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.beginningOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [.shift], + hasSelection: true + ), + .adjustSelection(.endOfLine) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(-1) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [.shift], + hasSelection: false + ), + .jumpToPrompt(1) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 21, + charactersIgnoringModifiers: "4", + modifierFlags: [], + hasSelection: true + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 33, + charactersIgnoringModifiers: "[", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertNil( + terminalKeyboardCopyModeAction( + keyCode: 30, + charactersIgnoringModifiers: "]", + modifierFlags: [], + hasSelection: false + ) + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 44, + charactersIgnoringModifiers: "/", + modifierFlags: [], + hasSelection: false + ), + .startSearch + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [], + hasSelection: false + ), + .searchNext + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 45, + charactersIgnoringModifiers: "n", + modifierFlags: [.shift], + hasSelection: false + ), + .searchPrevious + ) + } + + func testShiftVMatchesVisualToggleBehavior() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: false + ), + .startSelection + ) + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 9, + charactersIgnoringModifiers: "v", + modifierFlags: [.shift], + hasSelection: true + ), + .clearSelection + ) + } + + func testEscapeAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 53, + charactersIgnoringModifiers: "", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } + + func testQAlwaysExits() { + XCTAssertEqual( + terminalKeyboardCopyModeAction( + keyCode: 12, // kVK_ANSI_Q + charactersIgnoringModifiers: "q", + modifierFlags: [], + hasSelection: false + ), + .exit + ) + } +} + + +final class TerminalKeyboardCopyModeResolveTests: XCTestCase { + private func resolve( + _ keyCode: UInt16, + chars: String, + modifiers: NSEvent.ModifierFlags = [], + hasSelection: Bool, + state: inout TerminalKeyboardCopyModeInputState + ) -> TerminalKeyboardCopyModeResolution { + terminalKeyboardCopyModeResolve( + keyCode: keyCode, + charactersIgnoringModifiers: chars, + modifierFlags: modifiers, + hasSelection: hasSelection, + state: &state + ) + } + + func testCountPrefixAppliesToMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testZeroAppendsCountOrActsAsMotion() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20)) + + var selectionState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(29, chars: "0", hasSelection: true, state: &selectionState), + .perform(.adjustSelection(.beginningOfLine), count: 1) + ) + } + + func testYankLineOperatorSupportsYYAndYWithCounts() { + var yyState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1)) + + var countedState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume) + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4)) + + var shiftYState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume) + XCTAssertEqual( + resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState), + .perform(.copyLineAndExit, count: 3) + ) + } + + func testPendingYankLineDoesNotSwallowNextCommand() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testSearchAndPromptMotionsUseCounts() { + var promptState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume) + XCTAssertEqual( + resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState), + .perform(.jumpToPrompt(1), count: 3) + ) + + var searchState = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume) + XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2)) + } + + func testInvalidKeyClearsPendingState() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + // MARK: - gg (scroll to top via two-key sequence) + + func testGGScrollsToTop() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testGGWithSelectionAdjustsToHome() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testCountedGG() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5)) + } + + func testPendingGCancelledByOtherKey() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume) + XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1)) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + func testShiftGStillWorksImmediately() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state), + .perform(.scrollToBottom, count: 1) + ) + XCTAssertEqual(state, TerminalKeyboardCopyModeInputState()) + } + + // MARK: - Ctrl+U/D half-page scroll + + func testCtrlUHalfPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollHalfPage(-1), count: 1) + ) + } + + func testCtrlDHalfPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollHalfPage(1), count: 1) + ) + } + + func testCtrlBFullPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollPage(-1), count: 1) + ) + } + + func testCtrlFFullPage() { + var state = TerminalKeyboardCopyModeInputState() + XCTAssertEqual( + resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state), + .perform(.scrollPage(1), count: 1) + ) + } +} + + +final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase { + func testInitialViewportRowUsesImePointBaseline() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 24, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 240, + imeCellHeight: 24 + ), + 9 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 48, + imeCellHeight: 24, + topPadding: 24 + ), + 0 + ) + } + + func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() { + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 0, + imeCellHeight: 24 + ), + 0 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 9999, + imeCellHeight: 24 + ), + 23 + ) + XCTAssertEqual( + terminalKeyboardCopyModeInitialViewportRow( + rows: 24, + imePointY: 123, + imeCellHeight: 0 + ), + 23 + ) + } +} + + +final class GhosttyBackgroundThemeTests: XCTestCase { + func testColorClampsOpacity() { + let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + + let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) + XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) + + let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) + XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) + } + + func testColorFromNotificationUsesBackgroundAndOpacity() { + let fallbackColor = NSColor.black + let fallbackOpacity = 1.0 + let notification = Notification( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) + } + + func testColorFromNotificationFallsBackWhenPayloadMissing() { + let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) + let fallbackOpacity = 0.42 + let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) + } +} + + +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + + +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester", + applicationPathsByName: [String: String] = [:] + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) }, + isExecutableFileAtPath: { existingPaths.contains($0) }, + applicationPathForName: { applicationPathsByName[$0] } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testVSCodeInlineRequiresCodeTunnelExecutable() { + let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) + XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testTowerDetected() { + let env = environment(existingPaths: ["/Applications/Tower.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) + } + + func testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() { + let vscodePath = "/Volumes/Tools/Code.app" + let env = environment( + existingPaths: [ + vscodePath, + "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", + ], + applicationPathsByName: [ + "Code": vscodePath, + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.vscodeInline)) + } + + func testTowerDetectedViaApplicationLookupOutsideApplications() { + let towerPath = "/Volumes/Setapp/Tower.app" + let env = environment( + existingPaths: [towerPath], + applicationPathsByName: [ + "Tower": towerPath, + ] + ) + + XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + + +@MainActor +final class TerminalNotificationDirectInteractionTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + return window + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + fatalError("Failed to create key event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { + hostedView.subviews + .compactMap { $0 as? NSScrollView } + .first? + .documentView? + .subviews + .first + } + + func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + AppFocusState.overrideIsFocused = true + let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + surfaceView.mouseDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } + + func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + let event = makeKeyEvent(characters: "", keyCode: 122, window: window) + surfaceView.keyDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } +} + + +@MainActor +final class WindowTerminalHostViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + + func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { + let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + + XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10))) + } + + func testHostViewReturnsSubviewWhenSubviewIsHit() { + let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30)) + host.addSubview(child) + + XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child) + XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) + } + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowTerminalHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Host view must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } +} + + +@MainActor +final class GhosttySurfaceOverlayTests: XCTestCase { + private final class ScrollProbeSurfaceView: GhosttyNSView { + private(set) var scrollWheelCallCount = 0 + + override func scrollWheel(with event: NSEvent) { + scrollWheelCallCount += 1 + } + } + + private func findEditableTextField(in view: NSView) -> NSTextField? { + if let field = view as? NSTextField, field.isEditable { + return field + } + for subview in view.subviews { + if let field = findEditableTextField(in: subview) { + return field + } + } + return nil + } + + private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { + if firstResponder === textField { + return true + } + if let editor = firstResponder as? NSTextView, + editor.isFieldEditor, + editor.delegate as? NSTextField === textField { + return true + } + return false + } + + func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + XCTAssertFalse( + scrollView.acceptsFirstResponder, + "Host scroll view should not become first responder and steal terminal shortcuts" + ) + + _ = window.makeFirstResponder(nil) + + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: 0, + wheel2: -12, + wheel3: 0 + ), let scrollEvent = NSEvent(cgEvent: cgEvent) else { + XCTFail("Expected scroll wheel event") + return + } + + scrollView.scrollWheel(with: scrollEvent) + + XCTAssertEqual( + surfaceView.scrollWheelCallCount, + 1, + "Trackpad wheel events should be forwarded directly to Ghostty surface scrolling" + ) + XCTAssertTrue( + window.firstResponder === surfaceView, + "Scroll wheel handling should keep keyboard focus on terminal surface" + ) + } + + func testInactiveOverlayVisibilityTracksRequestedState() { + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) + ) + + hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true) + var state = hostedView.debugInactiveOverlayState() + XCTAssertFalse(state.isHidden) + XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01) + + hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false) + state = hostedView.debugInactiveOverlayState() + XCTAssertTrue(state.isHidden) + } + + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + ) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to be first responder before window blur" + ) + + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Window blur should force terminal surface to resign first responder" + ) + } + + func testSearchOverlayMountsAndUnmountsWithSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + + let searchState = TerminalSurface.SearchState(needle: "example") + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + } + + func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.debugHasSearchOverlay(), + "A stale deferred mount must not resurrect the find overlay after it closes" + ) + } + + func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + + XCTAssertTrue( + firstResponderOwnsTextField(window.firstResponder, textField: searchField), + "Deferred search overlay attach should still move focus into the find field" + ) + } + + func testStartOrFocusTerminalSearchReusesExistingSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let existingSearchState = TerminalSurface.SearchState(needle: "existing") + surface.searchState = existingSearchState + + var focusNotificationCount = 0 + XCTAssertTrue( + startOrFocusTerminalSearch(surface) { _ in + focusNotificationCount += 1 + } + ) + + XCTAssertTrue(surface.searchState === existingSearchState) + XCTAssertEqual( + focusNotificationCount, + 1, + "Re-triggering terminal Find should refocus the existing overlay without recreating state" + ) + } + + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil + window.orderOut(nil) + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let searchState = TerminalSurface.SearchState(needle: "") + surface.searchState = searchState + hostedView.setSearchOverlay(searchState: searchState) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let searchField = findEditableTextField(in: hostedView) else { + XCTFail("Expected mounted find text field") + return + } + window.makeFirstResponder(searchField) + + var escapeKeyUpCount = 0 + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return } + escapeKeyUpCount += 1 + } + + let timestamp = ProcessInfo.processInfo.systemUptime + guard let escapeKeyDown = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ), let escapeKeyUp = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: [], + timestamp: timestamp + 0.001, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + ) else { + XCTFail("Failed to construct Escape key events") + return + } + + NSApp.sendEvent(escapeKeyDown) + NSApp.sendEvent(escapeKeyUp) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty") + XCTAssertEqual( + escapeKeyUpCount, + 0, + "Escape used to dismiss find overlay must not pass through to the terminal key-up path" + ) + } + + @MainActor + func testKeyboardCopyModeIndicatorMountsAndUnmounts() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.syncKeyStateIndicator(text: "vim") + XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) + + hostedView.syncKeyStateIndicator(text: nil) + XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) + } + + @MainActor + func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) + let surfaceView = GhosttyNSView(frame: .zero) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = container.bounds + container.addSubview(hostedView) + + hostedView.setDropZoneOverlay(zone: .right) + container.layoutSubtreeIfNeeded() + + let state = hostedView.debugDropZoneOverlayState() + XCTAssertFalse(state.isHidden) + XCTAssertFalse( + state.isAttachedToHostedView, + "Drop-hover overlay should be mounted outside the hosted terminal view" + ) + XCTAssertTrue( + state.isAttachedToParentContainer, + "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout" + ) + XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5) + XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5) + XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5) + + hostedView.setDropZoneOverlay(zone: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden) + } + + func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { +#if DEBUG + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.reconcileGeometryNow() + surface.releaseSurfaceForTesting() + XCTAssertNil(surface.surface, "Surface should be nil after test release helper") + + hostedView.reconcileGeometryNow() + surface.forceRefresh() + XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil") +#else + throw XCTSkip("Debug-only regression test") +#endif + } + + func testSearchOverlayMountDoesNotRetainTerminalSurface() { + weak var weakSurface: TerminalSurface? + + let hostedView: GhosttySurfaceScrollView = { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + weakSurface = surface + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check")) + return hostedView + }() + + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface") + } + + func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140)) + let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140)) + contentView.addSubview(anchorA) + contentView.addSubview(anchorB) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Split-like anchor churn should not unmount terminal search overlay" + ) + } + + func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160)) + contentView.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Workspace-switch-like visibility toggles should not unmount terminal search overlay" + ) + } +} + + +@MainActor +final class TerminalWindowPortalLifecycleTests: XCTestCase { + private final class ContentViewCountingWindow: NSWindow { + var contentViewReadCount = 0 + + override var contentView: NSView? { + get { + contentViewReadCount += 1 + return super.contentView + } + set { + super.contentView = newValue + } + } + } + + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + 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), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + _ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Portal host must remain above content view so portal-hosted terminals stay visible" + ) + } + + func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), + let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertLessThan( + terminalHostIndex, + browserHostIndex, + message + ) + } + + assertHostOrder("Terminal portal host should start below browser portal host") + + let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150)) + contentView.addSubview(anchor) + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(anchor) + + assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host") + } + + func testRegistryPrunesPortalWhenWindowCloses() { + let baseline = TerminalWindowPortalRegistry.debugPortalCount() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + _ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window) + XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline) + } + + func testPruneDeadEntriesDetachesAnchorlessHostedView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hosted1 = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) + ) + + var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80)) + contentView.addSubview(anchor1!) + portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true) + + anchor1?.removeFromSuperview() + anchor1 = nil + + let hosted2 = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) + ) + let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80)) + contentView.addSubview(anchor2) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) + + XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked") + XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") + } + + func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() { + let window = ContentViewCountingWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120)) + contentView.addSubview(anchor) + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + + let baselineReads = window.contentViewReadCount + for _ in 0..<25 { + portal.synchronizeHostedViewForAnchor(anchor) + } + + XCTAssertEqual( + window.contentViewReadCount, + baselineReads, + "Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView" + ) + } + + func testTerminalViewAtWindowPointResolvesPortalHostedSurface() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + + let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let windowPoint = anchor.convert(center, to: nil) + XCTAssertNotNil( + portal.terminalViewAtWindowPoint(windowPoint), + "Portal hit-testing should resolve the terminal view for Finder file drops" + ) + } + + func testVisibilityTransitionBringsHostedViewToFront() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) + let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) + let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) + + let overlapInContent = NSPoint(x: 120, y: 100) + let overlapInWindow = contentView.convert(overlapInContent, to: nil) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, + "Latest bind should be top-most before visibility transition" + ) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false) + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, + "Becoming visible should refresh z-order for already-hosted view" + ) + } + + func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180)) + let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1) + let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2) + + let overlapInContent = NSPoint(x: 120, y: 100) + let overlapInWindow = contentView.convert(overlapInContent, to: nil) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2, + "Higher-priority terminal should initially be top-most" + ) + + portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2) + XCTAssertTrue( + portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1, + "Promoting z-priority should bring an already-visible terminal to front" + ) + } + + func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let portal = WindowTerminalPortal(window: window) + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") + + // Collapse to a tiny frame first. + anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") + + // Then restore to a non-zero but still too-small frame. It should remain hidden. + anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue( + hosted.isHidden, + "Portal should defer reveal until geometry reaches a usable size" + ) + + // Once the frame is large enough again, reveal should resume. + anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") + } + + func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { + 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) + } + + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) + shiftedContainer.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + shiftedContainer.frame.origin.x += 96 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + + let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Before the external geometry sync, hit-testing should still point at the stale portal location" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "The stale portal position should be cleared after the scheduled external geometry sync" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" + ) + } + + func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { + 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) + + 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.scheduleExternalGeometrySynchronize(for: window) + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.maxX, + originalAnchorFrameInWindow.maxX + 1, + "The shifted anchor should expose a new trailing region outside the stale portal frame" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "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" + ) + } +} + + +final class TerminalOpenURLTargetResolutionTests: XCTestCase { + func testResolvesHTTPSAsEmbeddedBrowser() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1")) + switch target { + case let .embeddedBrowser(url): + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "example.com") + XCTAssertEqual(url.path, "/path") + default: + XCTFail("Expected web URL to route to embedded browser") + } + } + + func testResolvesBareDomainAsEmbeddedBrowser() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs")) + switch target { + case let .embeddedBrowser(url): + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "example.com") + XCTAssertEqual(url.path, "/docs") + default: + XCTFail("Expected bare domain to be normalized as an HTTPS browser URL") + } + } + + func testResolvesFileSchemeAsExternal() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt")) + switch target { + case let .external(url): + XCTAssertTrue(url.isFileURL) + XCTAssertEqual(url.path, "/tmp/cmux.txt") + default: + XCTFail("Expected file URL to open externally") + } + } + + func testResolvesAbsolutePathAsExternalFileURL() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt")) + switch target { + case let .external(url): + XCTAssertTrue(url.isFileURL) + XCTAssertEqual(url.path, "/tmp/cmux-path.txt") + default: + XCTFail("Expected absolute file path to open externally") + } + } + + func testResolvesNonWebSchemeAsExternal() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com")) + switch target { + case let .external(url): + XCTAssertEqual(url.scheme, "mailto") + default: + XCTFail("Expected non-web scheme to open externally") + } + } + + func testResolvesHostlessHTTPSAsExternal() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt")) + switch target { + case let .external(url): + XCTAssertEqual(url.scheme, "https") + XCTAssertNil(url.host) + XCTAssertEqual(url.path, "/tmp/cmux.txt") + default: + XCTFail("Expected hostless HTTPS URL to open externally") + } + } +} + + +final class TerminalControllerSocketTextChunkTests: XCTestCase { + func testSocketTextChunksReturnsSingleChunkForPlainText() { + XCTAssertEqual( + TerminalController.socketTextChunks("echo hello"), + [.text("echo hello")] + ) + } + + func testSocketTextChunksSplitsControlScalars() { + XCTAssertEqual( + TerminalController.socketTextChunks("abc\rdef\tghi"), + [ + .text("abc"), + .control("\r".unicodeScalars.first!), + .text("def"), + .control("\t".unicodeScalars.first!), + .text("ghi") + ] + ) + } + + func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() { + XCTAssertEqual( + TerminalController.socketTextChunks("\r\n\t"), + [ + .control("\r".unicodeScalars.first!), + .control("\n".unicodeScalars.first!), + .control("\t".unicodeScalars.first!) + ] + ) + } +} + + +final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { + func testImmediateStateUpdateAllowedWhenHostNotInWindow() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: true + ) + ) + } + + func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { + XCTAssertFalse( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostedViewHasSuperview: false, + isBoundToCurrentHost: false + ) + ) + } + + func testInteractiveGeometryResizeUsesImmediatePortalSyncDecision() { + XCTAssertTrue( + GhosttyTerminalView.shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: false, + windowInLiveResize: false, + interactiveGeometryResizeActive: true + ), + "Interactive resize should use the immediate portal sync path" + ) + } +} + + +final class TerminalControllerSocketListenerHealthTests: XCTestCase { + func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { + XCTAssertEqual( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: SocketControlSettings.stableDefaultSocketPath, + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ), + SocketControlSettings.userScopedStableSocketPath(currentUserID: 501) + ) + } + + func testNonStableSocketBindFailureDoesNotFallback() { + XCTAssertNil( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: "/tmp/cmux-debug.sock", + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ) + ) + } + + private func makeTempSocketPath() -> String { + "/tmp/cmux-socket-health-\(UUID().uuidString).sock" + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] + ) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strcpy(pathBuf, ptr) + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] + ) + } + + guard Darwin.listen(fd, 1) == 0 else { + let code = Int(errno) + Darwin.close(fd) + throw NSError( + domain: NSPOSIXErrorDomain, + code: code, + userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] + ) + } + + return fd + } + + private func acceptSingleClient( + on listenerFD: Int32, + handler: @escaping (_ clientFD: Int32) -> Void + ) -> XCTestExpectation { + let handled = expectation(description: "socket client handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + handler(clientFD) + } + return handled + } + + @MainActor + func testSocketListenerHealthRecognizesSocketPath() throws { + let path = makeTempSocketPath() + let fd = try bindUnixSocket(at: path) + defer { + Darwin.close(fd) + unlink(path) + } + + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) + XCTAssertTrue(health.socketPathExists) + XCTAssertFalse(health.isHealthy) + } + + @MainActor + func testSocketListenerHealthRejectsRegularFile() throws { + let path = makeTempSocketPath() + let url = URL(fileURLWithPath: path) + try "not-a-socket".write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path) + XCTAssertFalse(health.socketPathExists) + XCTAssertFalse(health.isHealthy) + } + + func testProbeSocketCommandReturnsFirstLineResponse() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + let response = "PONG\nextra\n" + _ = response.withCString { ptr in + write(clientFD, ptr, strlen(ptr)) + } + } + + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) + + XCTAssertEqual(response, "PONG") + wait(for: [handled], timeout: 1.0) + } + + func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let releaseServer = DispatchSemaphore(value: 0) + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + _ = releaseServer.wait(timeout: .now() + 1.0) + } + + let startedAt = Date() + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) + let elapsed = Date().timeIntervalSince(startedAt) + releaseServer.signal() + + XCTAssertNil(response) + XCTAssertGreaterThanOrEqual(elapsed, 0.18) + XCTAssertLessThan(elapsed, 0.8) + wait(for: [handled], timeout: 1.0) + } + + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { + let health = TerminalController.SocketListenerHealth( + isRunning: true, + acceptLoopAlive: true, + socketPathMatches: true, + socketPathExists: true + ) + XCTAssertTrue(health.isHealthy) + XCTAssertTrue(health.failureSignals.isEmpty) + } + + func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { + let health = TerminalController.SocketListenerHealth( + isRunning: false, + acceptLoopAlive: false, + socketPathMatches: false, + socketPathExists: false + ) + XCTAssertFalse(health.isHealthy) + XCTAssertEqual( + health.failureSignals, + ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] + ) + } +} diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift new file mode 100644 index 00000000..e618949f --- /dev/null +++ b/cmuxTests/WindowAndDragTests.swift @@ -0,0 +1,1082 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateWindowContextRoutingTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowB.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) + XCTAssertTrue(app.tabManager === managerB) + + windowA.makeKeyAndOrderFront(nil) + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // Seed active manager and clear focus windows to force fallback routing. + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + windowA.orderOut(nil) + windowB.orderOut(nil) + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) + XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // SwiftUI can replace the NSWindow identifier string at runtime. + window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) + XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") + XCTAssertTrue(app.tabManager === manager) + } + + func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + + let originalSelectedA = managerA.selectedTabId + let originalSelectedB = managerB.selectedTabId + let originalTabCountB = managerB.tabs.count + + let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false) + + XCTAssertNotNil(createdWorkspaceId) + XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing") + XCTAssertEqual(managerA.selectedTabId, originalSelectedA) + XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab") + XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) + XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) + } + + func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + window.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) + + let defaults = UserDefaults.standard + let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) + defaults.set(true, forKey: WelcomeSettings.shownKey) + defer { + if let previousWelcomeShown { + defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) + } else { + defaults.removeObject(forKey: WelcomeSettings.shownKey) + } + } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let existingWorkspaceIds = Set(manager.tabs.map(\.id)) + + app.application( + NSApplication.shared, + open: [URL(fileURLWithPath: droppedDirectory.path)] + ) + + let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } + XCTAssertNotNil(createdWorkspace) + XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) + } +} + + +@MainActor +final class AppDelegateLaunchServicesRegistrationTests: XCTestCase { + func testScheduleLaunchServicesRegistrationDefersRegisterWork() { + _ = NSApplication.shared + let app = AppDelegate() + + var scheduledWork: (@Sendable () -> Void)? + var registerCallCount = 0 + + app.scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"), + scheduler: { work in + scheduledWork = work + }, + register: { _ in + registerCallCount += 1 + return noErr + } + ) + + XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path") + XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler") + + scheduledWork?() + + XCTAssertEqual(registerCallCount, 1) + } +} + + +final class FocusFlashPatternTests: XCTestCase { + func testFocusFlashPatternMatchesTerminalDoublePulseShape() { + XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) + XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) + XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) + } + + func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { + let segments = FocusFlashPattern.segments + XCTAssertEqual(segments.count, 4) + + XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001) + XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[0].curve, .easeOut) + + XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[1].curve, .easeIn) + + XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) + XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[2].curve, .easeOut) + + XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) + XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[3].curve, .easeIn) + } +} + + +@MainActor +final class InternalTabDragConfigurationTests: XCTestCase { + func testDisablesExternalOperationsForInternalTabDrags() throws { + guard #available(macOS 26.0, *) else { + throw XCTSkip("Requires macOS 26 drag configuration APIs") + } + + let configuration = InternalTabDragConfigurationProvider.value + let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp) + let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp) + + XCTAssertEqual( + withinApp, + DragConfigurationOperationsSnapshot( + allowCopy: false, + allowMove: true, + allowDelete: false, + allowAlias: false + ) + ) + + XCTAssertEqual( + outsideApp, + DragConfigurationOperationsSnapshot( + allowCopy: false, + allowMove: false, + allowDelete: false, + allowAlias: false + ) + ) + } +} + + +@MainActor +final class InternalTabDragBundleDeclarationTests: XCTestCase { + private func exportedTypeIdentifiers(bundle: Bundle) -> Set { + let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? [] + return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String }) + } + + func testAppBundleExportsInternalDragTypes() { + let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self)) + + XCTAssertTrue( + exported.contains("com.splittabbar.tabtransfer"), + "Expected app bundle to export bonsplit tab-transfer type, got \(exported)" + ) + XCTAssertTrue( + exported.contains("com.cmux.sidebar-tab-reorder"), + "Expected app bundle to export sidebar tab-reorder type, got \(exported)" + ) + } +} +#endif + + +@MainActor +final class WindowDragHandleHitTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class HostContainerView: NSView {} + private final class BlockingTopHitContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + private final class PassThroughProbeView: NSView { + var onHitTest: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + onHitTest?() + return nil + } + } + private final class PassiveHostContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + return super.hitTest(point) ?? self + } + } + + private final class MutatingSiblingView: NSView { + weak var container: NSView? + private var didMutate = false + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + guard !didMutate, let container else { return nil } + didMutate = true + let transient = NSView(frame: .zero) + container.addSubview(transient) + transient.removeFromSuperview() + return nil + } + } + + private final class ReentrantDragHandleView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window) + return shouldCapture ? self : nil + } + } + + /// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit, + /// simulating the crash path where sibling.hitTest triggers a SwiftUI layout + /// pass that calls back into the drag handle's hit resolution. + private final class ReentrantSiblingView: NSView { + weak var dragHandle: NSView? + var reenteredResult: Bool? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), let dragHandle else { return nil } + // Simulate the re-entry: during sibling hit test, SwiftUI layout + // calls windowDragHandleShouldCaptureHit on the drag handle again. + reenteredResult = windowDragHandleShouldCaptureHit( + point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window + ) + return nil + } + } + + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Empty titlebar space should drag the window" + ) + } + + func testDragHandleYieldsWhenSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + container.addSubview(folderIconHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), + "Interactive titlebar controls should receive the mouse event" + ) + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + hidden.isHidden = true + container.addSubview(hidden) + + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleDoesNotCaptureOutsideBounds() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleSkipsCaptureForPassivePointerEvents() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let point = NSPoint(x: 180, y: 18) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved)) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate)) + XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil)) + XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown)) + } + + func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() { + let point = NSPoint(x: 180, y: 18) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + container.autoresizingMask = [.width, .height] + contentView.addSubview(container) + + let dragHandle = NSView(frame: container.bounds) + dragHandle.autoresizingMask = [.width, .height] + container.addSubview(dragHandle) + + let foreignWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { foreignWindow.orderOut(nil) } + + XCTAssertFalse( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: nil + ), + "Launch activation events without a matching window should not trigger drag-handle hierarchy walk" + ) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: foreignWindow + ), + "Left mouse-down events for a different window should be treated as passive" + ) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit( + point, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: window + ), + "Left mouse-down events for this window should still capture empty titlebar space" + ) + } + + func testPassiveHostingTopHitClassification() { + XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) + XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) + } + + func testDragHandleIgnoresPassiveHostSiblingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + container.addSubview(passiveHost) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Passive host wrappers should not block titlebar drag capture" + ) + } + + func testDragHandleRespectsInteractiveChildInsidePassiveHost() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + passiveHost.addSubview(folderControl) + container.addSubview(passiveHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown), + "Interactive controls inside passive host wrappers should still receive hits" + ) + } + + func testTopHitResolutionStateIsScopedPerWindow() { + let point = NSPoint(x: 100, y: 18) + + let outerWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { outerWindow.orderOut(nil) } + guard let outerContentView = outerWindow.contentView else { + XCTFail("Expected outer content view") + return + } + let outerContainer = NSView(frame: outerContentView.bounds) + outerContainer.autoresizingMask = [.width, .height] + outerContentView.addSubview(outerContainer) + let outerDragHandle = NSView(frame: outerContainer.bounds) + outerDragHandle.autoresizingMask = [.width, .height] + outerContainer.addSubview(outerDragHandle) + + let nestedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { nestedWindow.orderOut(nil) } + guard let nestedContentView = nestedWindow.contentView else { + XCTFail("Expected nested content view") + return + } + let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds) + nestedContainer.autoresizingMask = [.width, .height] + nestedContentView.addSubview(nestedContainer) + let nestedDragHandle = NSView(frame: nestedContainer.bounds) + nestedDragHandle.autoresizingMask = [.width, .height] + nestedContainer.addSubview(nestedDragHandle) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow), + "Nested window drag handle should be blocked by top-hit titlebar container" + ) + + var nestedCaptureResult: Bool? + let probe = PassThroughProbeView(frame: outerContainer.bounds) + probe.autoresizingMask = [.width, .height] + probe.onHitTest = { + nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow) + } + outerContainer.addSubview(probe) + + _ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow) + + XCTAssertEqual( + nestedCaptureResult, + false, + "Top-hit recursion in one window must not disable top-hit resolution in another window" + ) + } + + func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let mutatingSibling = MutatingSiblingView(frame: container.bounds) + mutatingSibling.container = container + container.addSubview(mutatingSibling) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown), + "Subview mutations during hit testing should not crash or break drag-handle capture" + ) + } + + func testDragHandleSiblingHitTestReentrancyDoesNotCrash() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let reentrantSibling = ReentrantSiblingView(frame: container.bounds) + reentrantSibling.dragHandle = dragHandle + container.addSubview(reentrantSibling) + + // The outer call enters the sibling walk, which calls + // reentrantSibling.hitTest(), which re-enters + // windowDragHandleShouldCaptureHit. Without the re-entrancy guard + // this would trigger a Swift exclusive-access violation (SIGABRT). + let outerResult = windowDragHandleShouldCaptureHit( + NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown + ) + XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil") + XCTAssertEqual( + reentrantSibling.reenteredResult, false, + "Re-entrant call should bail out (return false) instead of crashing" + ) + } + + func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() { + let point = NSPoint(x: 180, y: 18) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 220, height: 36), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + container.autoresizingMask = [.width, .height] + contentView.addSubview(container) + + let dragHandle = ReentrantDragHandleView(frame: container.bounds) + dragHandle.autoresizingMask = [.width, .height] + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window), + "Reentrant same-window top-hit resolution should not trigger exclusivity crashes" + ) + } +} + +#if DEBUG + + +@MainActor +final class DraggableFolderHitTests: XCTestCase { + func testFolderHitTestReturnsContainerWhenInsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { + XCTFail("Expected folder icon to capture inside hit") + return + } + XCTAssertTrue(hit === folderView) + } + + func testFolderHitTestReturnsNilOutsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) + } + + func testFolderIconDisablesWindowMoveBehavior() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertFalse(folderView.mouseDownCanMoveWindow) + } +} + + +@MainActor +final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { + func testLeadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) + } + + func testLeadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertFalse(view.mouseDownCanMoveWindow) + } +} + + +@MainActor +final class FolderWindowMoveSuppressionTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testSuppressionDisablesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, true) + XCTAssertFalse(window.isMovable) + } + + func testSuppressionPreservesAlreadyImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testRestoreAppliesPreviousMovableState() { + let window = makeWindow() + window.isMovable = false + + restoreWindowDragging(window: window, previousMovableState: true) + XCTAssertTrue(window.isMovable) + + restoreWindowDragging(window: window, previousMovableState: false) + XCTAssertFalse(window.isMovable) + } + + func testWindowDragSuppressionDepthLifecycle() { + let window = makeWindow() + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testWindowDragSuppressionIsReferenceCounted() { + let window = makeWindow() + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(beginWindowDragSuppression(window: window), 2) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testTemporaryWindowMovableEnableRestoresImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testTemporaryWindowMovableEnablePreservesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, true) + XCTAssertTrue(window.isMovable) + } +} + + +@MainActor +final class WindowMoveSuppressionHitPathTests: XCTestCase { + private func makeWindowWithContentView() -> (NSWindow, NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + return (window, contentView) + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testSuppressionHitPathRecognizesFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) + } + + func testSuppressionHitPathRecognizesDescendantOfFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + let child = NSView(frame: .zero) + folderView.addSubview(child) + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) + } + + func testSuppressionHitPathIgnoresUnrelatedViews() { + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) + } + + func testSuppressionEventPathRecognizesFolderHitInsideWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) + contentView.addSubview(folderView) + + let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) + } + + func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + contentView.addSubview(plainView) + + let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) + + let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) + } +} + + +@MainActor +final class FileDropOverlayViewTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + let overlay = FileDropOverlayView(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay, positioned: .above, relativeTo: nil) + + let point = anchor.convert( + NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), + to: nil + ) + XCTAssertTrue( + overlay.webViewUnderPoint(point) === webView, + "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" + ) + } +} + + +@MainActor +final class MarkdownPanelPointerObserverViewTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow, + eventNumber: Int = 1 + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: eventNumber, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create mouse event") + } + return event + } + + func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let focusExpectation = expectation(description: "observer forwards focus callback") + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + focusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) + ) + wait(for: [focusExpectation], timeout: 1.0) + + XCTAssertEqual(pointerDownCount, 1) + } + + func testObserverIgnoresOutsideOrForeignWindowClicks() { + let window = makeWindow() + defer { window.orderOut(nil) } + let otherWindow = makeWindow() + defer { otherWindow.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let noFocusExpectation = expectation(description: "observer ignores invalid clicks") + noFocusExpectation.isInverted = true + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + noFocusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) + ) + wait(for: [noFocusExpectation], timeout: 0.1) + + XCTAssertEqual(pointerDownCount, 0) + } + + func testObserverDoesNotParticipateInHitTesting() { + let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) + } +} diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 6e8d62e3..d3759a0c 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -7,6 +7,36 @@ import XCTest #endif final class WorkspaceContentViewVisibilityTests: XCTestCase { + func testBackgroundPrimedWorkspaceStaysMountedButNotPanelVisible() { + XCTAssertEqual( + MountedWorkspacePresentationPolicy.resolve( + isSelectedWorkspace: false, + isRetiringWorkspace: false, + shouldPrimeInBackground: true + ), + MountedWorkspacePresentation( + isRenderedVisible: false, + isPanelVisible: false, + renderOpacity: 0.001 + ) + ) + } + + func testRetiringWorkspaceStaysPanelVisibleDuringHandoff() { + XCTAssertEqual( + MountedWorkspacePresentationPolicy.resolve( + isSelectedWorkspace: false, + isRetiringWorkspace: true, + shouldPrimeInBackground: false + ), + MountedWorkspacePresentation( + isRenderedVisible: true, + isPanelVisible: true, + renderOpacity: 1 + ) + ) + } + func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { XCTAssertFalse( WorkspaceContentView.panelVisibleInUI( diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift new file mode 100644 index 00000000..e3cc26e1 --- /dev/null +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -0,0 +1,82 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class WorkspacePullRequestSidebarTests: XCTestCase { + func testSidebarPullRequestsIgnoreStaleWorkspaceLevelCacheWithoutPanelState() throws { + let workspace = Workspace(title: "Test") + let panelId = UUID() + let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640")) + + workspace.pullRequest = SidebarPullRequestState( + number: 1640, + label: "PR", + url: staleURL, + status: .open, + branch: "main" + ) + workspace.gitBranch = SidebarGitBranchState(branch: "main", isDirty: false) + + XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [panelId]), []) + } + + func testSidebarPullRequestsFilterBranchMismatchPerPanel() throws { + let workspace = Workspace(title: "Test") + let panelId = UUID() + let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640")) + + workspace.panelGitBranches[panelId] = SidebarGitBranchState(branch: "main", isDirty: false) + workspace.panelPullRequests[panelId] = SidebarPullRequestState( + number: 1640, + label: "PR", + url: staleURL, + status: .open, + branch: "feature/old" + ) + + XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [panelId]), []) + } + + func testSidebarPullRequestsPreferBestStateAcrossPanels() throws { + let workspace = Workspace(title: "Test") + let firstPanelId = UUID() + let secondPanelId = UUID() + let url = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1640")) + + workspace.panelGitBranches[firstPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false) + workspace.panelGitBranches[secondPanelId] = SidebarGitBranchState(branch: "feature/work", isDirty: false) + workspace.panelPullRequests[firstPanelId] = SidebarPullRequestState( + number: 1640, + label: "PR", + url: url, + status: .open, + branch: "feature/work", + checks: .pass + ) + workspace.panelPullRequests[secondPanelId] = SidebarPullRequestState( + number: 1640, + label: "PR", + url: url, + status: .merged, + branch: "feature/work" + ) + + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: [firstPanelId, secondPanelId]), + [ + SidebarPullRequestState( + number: 1640, + label: "PR", + url: url, + status: .merged, + branch: "feature/work" + ) + ] + ) + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift new file mode 100644 index 00000000..5bd83682 --- /dev/null +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -0,0 +1,1899 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + +final class SidebarSelectedWorkspaceColorTests: XCTestCase { + func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + + +final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { + func testRenameTabShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") + + let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWindowShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") + + let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertTrue(shortcut.control) + } + + func testRenameWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testRenameWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testCloseWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab") + + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertEqual(nextShortcut.key, "]") + XCTAssertTrue(nextShortcut.command) + XCTAssertFalse(nextShortcut.shift) + XCTAssertFalse(nextShortcut.option) + XCTAssertTrue(nextShortcut.control) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertEqual(prevShortcut.key, "[") + XCTAssertTrue(prevShortcut.command) + XCTAssertFalse(prevShortcut.shift) + XCTAssertFalse(prevShortcut.option) + XCTAssertTrue(prevShortcut.control) + } + + func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() { + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertNotNil(nextShortcut.keyEquivalent) + XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]") + XCTAssertTrue(nextShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(nextShortcut.eventModifiers.contains(.control)) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertNotNil(prevShortcut.keyEquivalent) + XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[") + XCTAssertTrue(prevShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) + } + + func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode") + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey, + "shortcut.toggleTerminalCopyMode" + ) + + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut + XCTAssertEqual(shortcut.key, "m") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { + XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertEqual( + StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent, + "\t" + ) + } + + func testShortcutDefaultsKeysRemainUnique() { + let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) + XCTAssertEqual(Set(keys).count, keys.count) + } +} + + +final class WorkspaceShortcutMapperTests: XCTestCase { + func testCommandNineMapsToLastWorkspaceIndex() { + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 4), 3) + XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 12), 11) + } + + func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() { + XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 0, workspaceCount: 12), 1) + XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 7, workspaceCount: 12), 8) + XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 11, workspaceCount: 12), 9) + XCTAssertNil(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 8, workspaceCount: 12)) + } +} + + +final class WorkspacePlacementSettingsTests: XCTestCase { + func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() { + let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent) + } + + func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() { + let suiteName = "WorkspacePlacementSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(NewWorkspacePlacement.top.rawValue, forKey: WorkspacePlacementSettings.placementKey) + XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .top) + + defaults.set("nope", forKey: WorkspacePlacementSettings.placementKey) + XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent) + } + + func testInsertionIndexTopInsertsBeforeUnpinned() { + let index = WorkspacePlacementSettings.insertionIndex( + placement: .top, + selectedIndex: 4, + selectedIsPinned: false, + pinnedCount: 2, + totalCount: 7 + ) + XCTAssertEqual(index, 2) + } + + func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() { + let afterUnpinned = WorkspacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndex: 3, + selectedIsPinned: false, + pinnedCount: 2, + totalCount: 6 + ) + XCTAssertEqual(afterUnpinned, 4) + + let afterPinned = WorkspacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndex: 0, + selectedIsPinned: true, + pinnedCount: 2, + totalCount: 6 + ) + XCTAssertEqual(afterPinned, 2) + } + + func testInsertionIndexEndAndNoSelectionAppend() { + let endIndex = WorkspacePlacementSettings.insertionIndex( + placement: .end, + selectedIndex: 1, + selectedIsPinned: false, + pinnedCount: 1, + totalCount: 5 + ) + XCTAssertEqual(endIndex, 5) + + let noSelectionIndex = WorkspacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndex: nil, + selectedIsPinned: false, + pinnedCount: 0, + totalCount: 5 + ) + XCTAssertEqual(noSelectionIndex, 5) + } +} + + +@MainActor +final class WorkspaceCreationPlacementTests: XCTestCase { + func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() { + let currentPlacement = WorkspacePlacementSettings.current() + + let defaultManager = makeManagerWithThreeWorkspaces() + let defaultBaselineOrder = defaultManager.tabs.map(\.id) + let defaultInserted = defaultManager.addWorkspace() + guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder) + + let explicitManager = makeManagerWithThreeWorkspaces() + let explicitBaselineOrder = explicitManager.tabs.map(\.id) + let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement) + guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder) + XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex) + } + + func testAddWorkspaceEndOverrideAlwaysAppends() { + let manager = makeManagerWithThreeWorkspaces() + let baselineCount = manager.tabs.count + guard baselineCount >= 3 else { + XCTFail("Expected at least three workspaces for placement regression test") + return + } + + let inserted = manager.addWorkspace(placementOverride: .end) + guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + + XCTAssertEqual(insertedIndex, baselineCount) + } + + private func makeManagerWithThreeWorkspaces() -> TabManager { + let manager = TabManager() + _ = manager.addWorkspace() + _ = manager.addWorkspace() + if let first = manager.tabs.first { + manager.selectWorkspace(first) + } + return manager + } +} + + +final class WorkspaceTabColorSettingsTests: XCTestCase { + func testNormalizedHexAcceptsAndNormalizesValidInput() { + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123") + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF") + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234")) + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) + } + + func testBuiltInPaletteMatchesOriginalPRPalette() { + let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + "#00AA33" + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { + let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults), + "#00AA33" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults), + "#112233" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults), + "#00AA33" + ) + XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + + XCTAssertEqual( + WorkspaceTabColorSettings.customColors(defaults: defaults), + ["#00AA33", "#112233"] + ) + } + + func testPaletteIncludesCustomEntriesAndResetClearsAll() { + let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) + _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) + + let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) + XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") + XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") + XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + + WorkspaceTabColorSettings.reset(defaults: defaults) + + XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testDisplayColorLightModeKeepsOriginalHex() { + let originalHex = "#1A5276" + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light + ) + + XCTAssertEqual(rendered?.hexString(), originalHex) + } + + func testDisplayColorDarkModeBrightensColor() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } + + func testDisplayColorDarkModeKeepsGrayscaleNeutral() { + let originalHex = "#808080" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ), + let renderedSRGB = rendered.usingColorSpace(.sRGB) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertGreaterThan(rendered.luminance, base.luminance) + XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003) + XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003) + } + + func testDisplayColorForceBrightensInLightMode() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light, + forceBright: true + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } +} + + +final class WorkspaceAutoReorderSettingsTests: XCTestCase { + func testDefaultIsEnabled() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testDisabledWhenSetToFalse() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testEnabledWhenSetToTrue() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } +} + + +final class SidebarWorkspaceDetailSettingsTests: XCTestCase { + func testDefaultPreferencesWhenUnset() { + let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) + XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) + XCTAssertTrue( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), + hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) + ) + ) + } + + func testStoredPreferencesOverrideDefaults() { + let suiteName = "SidebarWorkspaceDetailSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey) + defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey) + + XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) + XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) + XCTAssertFalse( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), + hideAllDetails: false + ) + ) + XCTAssertFalse( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: true, + hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) + ) + ) + } +} + + +final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase { + func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() { + XCTAssertEqual( + SidebarWorkspaceAuxiliaryDetailVisibility.resolved( + showMetadata: true, + showLog: false, + showProgress: true, + showBranchDirectory: false, + showPullRequests: true, + showPorts: false, + hideAllDetails: false + ), + SidebarWorkspaceAuxiliaryDetailVisibility( + showsMetadata: true, + showsLog: false, + showsProgress: true, + showsBranchDirectory: false, + showsPullRequests: true, + showsPorts: false + ) + ) + } + + func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() { + XCTAssertEqual( + SidebarWorkspaceAuxiliaryDetailVisibility.resolved( + showMetadata: true, + showLog: true, + showProgress: true, + showBranchDirectory: true, + showPullRequests: true, + showPorts: true, + hideAllDetails: true + ), + .hidden + ) + } +} + + +final class WorkspaceReorderTests: XCTestCase { + @MainActor + func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + XCTAssertTrue(manager.reorderWorkspace(tabId: second.id, toIndex: 0)) + XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id, third.id]) + XCTAssertEqual(manager.selectedTabId, second.id) + } + + @MainActor + func testReorderWorkspaceClampsOutOfRangeTargetIndex() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: first.id, toIndex: 999)) + XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id]) + } + + @MainActor + func testReorderWorkspaceReturnsFalseForUnknownWorkspace() { + let manager = TabManager() + XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0)) + } + + @MainActor + func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) + XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) + } + + @MainActor + func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) + XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) + } +} + + +@MainActor +final class WorkspaceNotificationReorderTests: XCTestCase { + func testNotificationAutoReorderDoesNotMovePinnedWorkspace() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let notificationStore = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let defaults = UserDefaults.standard + let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = notificationStore + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + AppFocusState.overrideIsFocused = false + + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalAutoReorderSetting { + defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key) + } else { + defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key) + } + } + + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id] + + notificationStore.addNotification( + tabId: secondPinned.id, + surfaceId: nil, + title: "Build finished", + subtitle: "", + body: "Pinned workspaces should stay put" + ) + + XCTAssertEqual(manager.tabs.map(\.id), expectedOrder) + } +} + + +@MainActor +final class WorkspaceTeardownTests: XCTestCase { + func testTeardownAllPanelsClearsPanelMetadataCaches() { + let workspace = Workspace() + guard let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel in new workspace") + return + } + + workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title") + workspace.setPanelPinned(panelId: initialPanelId, pinned: true) + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + + workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title") + workspace.setPanelPinned(panelId: splitPanel.id, pinned: true) + workspace.markPanelUnread(initialPanelId) + + XCTAssertFalse(workspace.panels.isEmpty) + XCTAssertFalse(workspace.panelTitles.isEmpty) + XCTAssertFalse(workspace.panelCustomTitles.isEmpty) + XCTAssertFalse(workspace.pinnedPanelIds.isEmpty) + XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty) + + workspace.teardownAllPanels() + + XCTAssertTrue(workspace.panels.isEmpty) + XCTAssertTrue(workspace.panelTitles.isEmpty) + XCTAssertTrue(workspace.panelCustomTitles.isEmpty) + XCTAssertTrue(workspace.pinnedPanelIds.isEmpty) + XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty) + } +} + + +@MainActor +final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { + func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + let workspace = Workspace() + guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" + guard let sourcePanel = workspace.newTerminalSurface( + inPane: sourcePaneId, + focus: false, + workingDirectory: requestedDirectory + ) else { + XCTFail("Expected source terminal panel to be created") + return + } + + XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertNil( + workspace.panelDirectories[sourcePanel.id], + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + XCTAssertEqual( + workspace.currentDirectory, + staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + guard let splitPanel = workspace.newTerminalSplit( + from: sourcePanel.id, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected split terminal panel to be created") + return + } + + XCTAssertEqual( + splitPanel.requestedWorkingDirectory, + requestedDirectory, + "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" + ) + } +} + + +@MainActor +final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + + func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + XCTAssertEqual( + workspace.focusedPanelId, + rightPanel.id, + "Expected the new split panel to be selected before simulating stale focus state" + ) + + // Simulate the split-pane failure mode: Bonsplit already points at the right panel, + // but the active terminal state is still stale on the left panel. + leftPanel.surface.setFocus(true) + leftPanel.hostedView.setActive(true) + rightPanel.surface.setFocus(false) + rightPanel.hostedView.setActive(false) + + workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) + + XCTAssertFalse( + leftPanel.hostedView.debugRenderStats().isActive, + "Expected stale left-pane active state to be cleared" + ) + XCTAssertTrue( + rightPanel.hostedView.debugRenderStats().isActive, + "Expected terminal-first-responder recovery to reactivate the selected split pane" + ) + } + + func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) + rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) + contentView.addSubview(leftPanel.hostedView) + contentView.addSubview(rightPanel.hostedView) + + leftPanel.hostedView.setVisibleInUI(true) + rightPanel.hostedView.setVisibleInUI(true) + leftPanel.hostedView.setFocusHandler { + workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) + } + rightPanel.hostedView.setFocusHandler { + workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertEqual( + workspace.focusedPanelId, + rightPanel.id, + "Expected the clicked split pane to already be selected before simulating stale focus state" + ) + + // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale + // active state remains on the left and the right pane's AppKit focus callback never fires + // after split reparent/layout churn. + leftPanel.surface.setFocus(true) + leftPanel.hostedView.setActive(true) + rightPanel.surface.setFocus(false) + rightPanel.hostedView.setActive(false) + rightPanel.hostedView.suppressReparentFocus() + + guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { + XCTFail("Expected right terminal surface view") + return + } + + let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + rightSurfaceView.mouseDown(with: event) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + leftPanel.hostedView.debugRenderStats().isActive, + "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed" + ) + XCTAssertTrue( + rightPanel.hostedView.debugRenderStats().isActive, + "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed" + ) + XCTAssertTrue( + rightPanel.hostedView.isSurfaceViewFirstResponder(), + "Expected the clicked split pane to become first responder" + ) + } +} + + +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + + +@MainActor +final class WorkspaceBrowserProfileSelectionTests: XCTestCase { + private final class RejectingCreateTabDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { + false + } + } + + private final class RejectingSplitPaneDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + false + } + } + + func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { + let workspace = Workspace() + let profileA = try makeTemporaryBrowserProfile(named: "Alpha") + let profileB = try makeTemporaryBrowserProfile(named: "Beta") + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browserA = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: profileA.id + ) + ) + _ = try XCTUnwrap( + workspace.newBrowserSplit( + from: browserA.id, + orientation: .horizontal, + preferredProfileID: profileB.id, + focus: true + ) + ) + + XCTAssertEqual( + workspace.preferredBrowserProfileID, + profileB.id, + "Expected workspace preference to drift to the most recently created browser profile" + ) + + let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) + workspace.bonsplitController.focusPane(paneId) + workspace.bonsplitController.selectTab(leftSurfaceId) + + let created = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false + ) + ) + + XCTAssertEqual( + created.profileID, + profileA.id, + "Expected new browser creation to inherit the selected browser profile from the target pane" + ) + } + + func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + _ = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingCreateTabDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: unexpectedProfile.id + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser creation to leave the workspace preferred profile unchanged" + ) + } + + func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingSplitPaneDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSplit( + from: browser.id, + orientation: .horizontal, + preferredProfileID: unexpectedProfile.id, + focus: false + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser split to leave the workspace preferred profile unchanged" + ) + } +} + + +@MainActor +final class WorkspacePanelGitBranchTests: XCTestCase { + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser split to preserve pre-split focus" + ) + } + + func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let terminalSplitPanel = workspace.newTerminalSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected terminal split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal split to preserve pre-split focus" + ) + } + + func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected initial panel and pane") + return + } + + XCTAssertEqual(workspace.panels.count, 1) +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: panelId) else { + XCTFail("Expected detach of last surface to succeed") + return + } + + XCTAssertEqual(detached.panelId, panelId) + XCTAssertTrue( + workspace.panels.isEmpty, + "Detaching the last surface should not auto-create a replacement panel" + ) + XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" + ) +#endif + + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) + XCTAssertEqual(restoredPanelId, panelId) + XCTAssertEqual(workspace.panels.count, 1) + } + + func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { + let workspace = Workspace() + guard let originalPanelId = workspace.focusedPanelId, + let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + XCTFail("Expected two panels before detach") + return + } + + drainMainQueue() + drainMainQueue() +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.panelId, movedPanel.id) + XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") + XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching into another workspace should not enqueue delayed source focus reconciliation" + ) +#endif + } + + func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { + let source = Workspace() + guard let panelId = source.focusedPanelId else { + XCTFail("Expected source focused panel") + return + } + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) + + guard let detached = source.detachSurface(panelId: panelId) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") + XCTAssertNil(detached.customTitle) + XCTAssertEqual( + detached.title, + "detached-runtime-title", + "Detached transfer should carry the cached non-custom title" + ) + + let destination = Workspace() + guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + XCTFail("Expected destination pane") + return + } + + let attachedPanelId = destination.attachDetachedSurface( + detached, + inPane: destinationPane, + focus: false + ) + XCTAssertEqual(attachedPanelId, panelId) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + + guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), + let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + XCTFail("Expected attached tab mapping") + return + } + XCTAssertEqual(attachedTab.title, "detached-runtime-title") + XCTAssertFalse(attachedTab.hasCustomTitle) + } + + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected focused pane for initial panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), + let splitTab = workspace.bonsplitController + .tabs(inPane: splitPaneId) + .first(where: { $0.id == splitTabId }) else { + XCTFail("Expected split pane/tab mapping") + return + } + + // Simulate one delayed stale split-selection callback from bonsplit. + DispatchQueue.main.async { + workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus split to reassert the pre-split focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.focusedPaneId, + originalPaneId, + "Expected focused pane to converge back to the pre-split pane" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to converge back to the pre-split focused panel" + ) + } + + func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + workspace.focusPanel(browserSplitPanel.id) + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + browserSplitPanel.id, + "Expected explicit focus intent to keep the split panel focused" + ) + } + + func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected terminal surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected browser surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { + let workspace = Workspace() + guard let firstPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) + guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + + workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true) + XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused") + XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix") + XCTAssertEqual(workspace.gitBranch?.isDirty, true) + + XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed") + XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused") + XCTAssertEqual(workspace.gitBranch?.branch, "main") + XCTAssertEqual(workspace.gitBranch?.isDirty, false) + } + + func testSidebarGitBranchesFollowLeftToRightSplitOrder() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false) + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true) + + let ordered = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"]) + XCTAssertEqual(ordered.map(\.isDirty), [false, true]) + } + + @MainActor + func testSidebarPullRequestsTrackFocusedPanelOnly() { + let workspace = Workspace() + guard let firstPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: firstPanelId), + let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else { + XCTFail("Expected focused panel and a second panel") + return + } + + workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false) + workspace.updatePanelPullRequest( + panelId: secondPanel.id, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open + ) + + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue( + workspace.sidebarPullRequestsInDisplayOrder().isEmpty, + "Expected background panel PRs to stay hidden while the focused panel has no PR" + ) + + workspace.focusPanel(secondPanel.id) + + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder().map(\.number), + [1629] + ) + } + + func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() { + let workspace = Workspace() + guard let leftFirstPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), + let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), + let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { + XCTFail("Expected panes and panels for ordering test") + return + } + + XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1)) + + workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true) + workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false) + + XCTAssertEqual( + workspace.sidebarOrderedPanelIds(), + [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id] + ) + + let branches = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"]) + XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) + } + + func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() { + let workspace = Workspace() + guard let leftFirstPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), + let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), + let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { + XCTFail("Expected panes and panels for precomputed ordering test") + return + } + + workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true) + workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false) + + workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root") + workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature") + workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root") + workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra") + + workspace.updatePanelPullRequest( + panelId: leftFirstPanelId, + number: 101, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!, + status: .open + ) + workspace.updatePanelPullRequest( + panelId: rightFirstPanel.id, + number: 18, + label: "MR", + url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!, + status: .merged + ) + + let orderedPanelIds = workspace.sidebarOrderedPanelIds() + + XCTAssertEqual( + workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" }, + workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" } + ) + XCTAssertEqual( + workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds), + workspace.sidebarBranchDirectoryEntriesInDisplayOrder() + ) + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds), + workspace.sidebarPullRequestsInDisplayOrder() + ) + } + + func testClosingPaneDropsBranchesFromClosedSide() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected left/right split panes") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false) + + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"]) + XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"]) + } +} + + +final class WorkspaceMountPolicyTests: XCTestCase { + func testDefaultPolicyMountsOnlySelectedWorkspace() { + let a = UUID() + let b = UUID() + let orderedTabIds: [UUID] = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces + ) + + XCTAssertEqual(next, [b]) + } + + func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() { + let a = UUID() + let b = UUID() + let c = UUID() + let orderedTabIds: [UUID] = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b, c], + selected: c, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: 2 + ) + + XCTAssertEqual(next, [c, a]) + } + + func testMissingWorkspacesArePruned() { + let a = UUID() + let b = UUID() + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [b, a], + selected: nil, + pinnedIds: [], + orderedTabIds: [a], + isCycleHot: false, + maxMounted: 2 + ) + + XCTAssertEqual(next, [a]) + } + + func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() { + let a = UUID() + let b = UUID() + let orderedTabIds: [UUID] = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: 2 + ) + + XCTAssertEqual(next, [b, a]) + } + + func testMaxMountedIsClampedToAtLeastOne() { + let a = UUID() + let b = UUID() + let orderedTabIds: [UUID] = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b], + selected: nil, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: 0 + ) + + XCTAssertEqual(next, [a]) + } + + func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() { + let a = UUID() + let b = UUID() + let c = UUID() + let d = UUID() + let orderedTabIds: [UUID] = [a, b, c, d] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: c, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + ) + + XCTAssertEqual(next, [c]) + } + + func testCycleHotModeRespectsMaxMountedLimit() { + let a = UUID() + let b = UUID() + let c = UUID() + let orderedTabIds: [UUID] = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a, b, c], + selected: b, + pinnedIds: [], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: 2 + ) + + XCTAssertEqual(next, [b]) + } + + func testPinnedIdsAreRetainedAcrossReconcile() { + let a = UUID() + let b = UUID() + let c = UUID() + let orderedTabIds: [UUID] = [a, b, c] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: c, + pinnedIds: [a], + orderedTabIds: orderedTabIds, + isCycleHot: false, + maxMounted: 2 + ) + + XCTAssertEqual(next, [c, a]) + } + + func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() { + let a = UUID() + let b = UUID() + let orderedTabIds: [UUID] = [a, b] + + let next = WorkspaceMountPolicy.nextMountedWorkspaceIds( + current: [a], + selected: b, + pinnedIds: [a], + orderedTabIds: orderedTabIds, + isCycleHot: true, + maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle + ) + + XCTAssertEqual(next, [b, a]) + } +} + + +@MainActor +final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { + override func setUp() { + super.setUp() + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + } + + override func tearDown() { + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + super.tearDown() + } + + func testHintWidthCachesRepeatedMeasurements() { + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0) + + let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertGreaterThan(first, 0) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertEqual(second, first) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2") + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2) + } + + func testSlotWidthAppliesMinimumAndDebugInset() { + let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999) + XCTAssertEqual(nilLabelWidth, 28) + + let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0) + let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10) + XCTAssertGreaterThan(widened, base) + } +} +#endif 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/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index e60e9def..aeb2c339 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -410,6 +410,39 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } + func testCmdShiftPCheckQueryPrefersCheckForUpdatesBeforeAttemptUpdate() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 1 + }, + "Expected the main window to be visible" + ) + + openCommandPaletteCommands(app: app) + let searchField = app.textFields["CommandPaletteSearchField"] + searchField.typeText("check") + + let row0 = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch + let row1 = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.1").firstMatch + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 5.0) { + row0.exists && + row1.exists && + (row0.value as? String) == "palette.checkForUpdates" && + (row1.value as? String) == "palette.attemptUpdate" + }, + "Expected the check query to rank Check for Updates before Attempt Update. row0=\(String(describing: row0.value)) row1=\(String(describing: row1.value))" + ) + XCTAssertEqual(row0.value as? String, "palette.checkForUpdates") + XCTAssertEqual(row1.value as? String, "palette.attemptUpdate") + } + func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws { let app = XCUIApplication() configureSocketControlledLaunch(app, showSettingsWindow: true) 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/web/app/[locale]/(legal)/eula/page.tsx b/web/app/[locale]/(legal)/eula/page.tsx index 85676b2d..ddc9ae0a 100644 --- a/web/app/[locale]/(legal)/eula/page.tsx +++ b/web/app/[locale]/(legal)/eula/page.tsx @@ -3,13 +3,14 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "EULA — cmux", description: "End-User License Agreement for cmux", + alternates: { canonical: "./" }, }; export default function EulaPage() { return ( <>

EULA

-

Last updated: December 2, 2025

+

Last updated: March 18, 2026

Please read this End-User License Agreement carefully before diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx index fc945d36..f0940728 100644 --- a/web/app/[locale]/(legal)/privacy-policy/page.tsx +++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx @@ -4,13 +4,14 @@ import { Link } from "../../../../i18n/navigation"; export const metadata: Metadata = { title: "Privacy Policy — cmux", description: "Privacy policy for cmux", + alternates: { canonical: "./" }, }; export default function PrivacyPolicyPage() { return ( <>

Privacy Policy

-

Last updated: December 2, 2025

+

Last updated: March 18, 2026

Manaflow (the “Company”) is committed to maintaining robust @@ -21,7 +22,7 @@ export default function PrivacyPolicyPage() {

For purposes of this policy, “Site” refers to the Company’s website at{" "} - cmux.dev. + cmux.com. “Application” refers to the cmux desktop application for macOS. “Service” refers to the Site and Application collectively. The terms “we,” “us,” and @@ -57,6 +58,13 @@ export default function PrivacyPolicyPage() { The Application checks for updates via Sparkle, which may transmit your operating system version and application version to our update server.

+

+ The Site uses PostHog for anonymous analytics, including page views and + navigation patterns. PostHog stores a cookie to distinguish unique + visitors. No personally identifiable information is collected through + analytics. You can opt out by using a browser extension that blocks + tracking scripts. +

2. Information you provide directly

@@ -90,6 +98,16 @@ export default function PrivacyPolicyPage() { Ghostty / libghostty — terminal rendering engine. Runs entirely locally on your device. +

  • + PostHog — website analytics. Collects anonymous + page view data, navigation patterns, and browser metadata via a + first-party proxy. No personally identifiable information is collected. +
  • +
  • + Resend — transactional email delivery. Used to + deliver feedback submissions from the Application. Your email address + is transmitted to Resend only if you voluntarily submit feedback. +
  • Each of these services has its own privacy policy governing the diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx index 6a0a72ec..56b4b98e 100644 --- a/web/app/[locale]/(legal)/terms-of-service/page.tsx +++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx @@ -3,17 +3,18 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "Terms of Service — cmux", description: "Terms of service for cmux", + alternates: { canonical: "./" }, }; export default function TermsOfServicePage() { return ( <>

    Terms of Service

    -

    Last revised on: December 2, 2025

    +

    Last revised on: March 18, 2026

    The website located at{" "} - cmux.dev (the + cmux.com (the “Site”) and the cmux desktop application (the “Application”) are copyrighted works belonging to Manaflow (“Company”, “us”, “our”, and @@ -180,7 +181,7 @@ export default function TermsOfServicePage() {

    - Copyright © 2025 Manaflow. All rights reserved. + Copyright © {new Date().getFullYear()} Manaflow. All rights reserved.

    ); diff --git a/web/app/[locale]/community/page.tsx b/web/app/[locale]/community/page.tsx index cce06e02..3742df3b 100644 --- a/web/app/[locale]/community/page.tsx +++ b/web/app/[locale]/community/page.tsx @@ -8,6 +8,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: { canonical: "./" }, }; } diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx index 4e1f6a79..cc230873 100644 --- a/web/app/[locale]/layout.tsx +++ b/web/app/[locale]/layout.tsx @@ -31,7 +31,7 @@ export async function generateMetadata({ const { locale } = await params; const t = await getTranslations({ locale, namespace: "meta" }); const url = - locale === "en" ? "https://cmux.dev" : `https://cmux.dev/${locale}`; + locale === "en" ? "https://cmux.com" : `https://cmux.com/${locale}`; return { title: t("title"), description: t("description"), @@ -61,7 +61,7 @@ export async function generateMetadata({ title: t("title"), description: t("ogDescription"), }, - metadataBase: new URL("https://cmux.dev"), + metadataBase: new URL("https://cmux.com"), }; } @@ -94,7 +94,7 @@ export default async function LocaleLayout({ name: "cmux", operatingSystem: "macOS", applicationCategory: "DeveloperApplication", - url: "https://cmux.dev", + url: "https://cmux.com", downloadUrl: "https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg", description: diff --git a/web/app/[locale]/nightly/page.tsx b/web/app/[locale]/nightly/page.tsx index 35af11df..d3fb1a31 100644 --- a/web/app/[locale]/nightly/page.tsx +++ b/web/app/[locale]/nightly/page.tsx @@ -12,6 +12,7 @@ export async function generateMetadata({ return { title: t("metaTitle"), description: t("metaDescription"), + alternates: { canonical: "./" }, }; } diff --git a/web/app/[locale]/posthog.tsx b/web/app/[locale]/posthog.tsx index 8c924c3c..985550ba 100644 --- a/web/app/[locale]/posthog.tsx +++ b/web/app/[locale]/posthog.tsx @@ -7,7 +7,7 @@ import { useEffect, Suspense } from "react"; if (typeof window !== "undefined") { posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", { - api_host: "https://r.cmux.dev", + api_host: "https://r.cmux.com", ui_host: "https://us.posthog.com", person_profiles: "identified_only", capture_pageview: false, diff --git a/web/app/[locale]/wall-of-love/page.tsx b/web/app/[locale]/wall-of-love/page.tsx index 5f26b0d8..c1ec7d41 100644 --- a/web/app/[locale]/wall-of-love/page.tsx +++ b/web/app/[locale]/wall-of-love/page.tsx @@ -9,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: { canonical: "./" }, }; } diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts index 33256634..96560d1c 100644 --- a/web/app/api/feedback/route.ts +++ b/web/app/api/feedback/route.ts @@ -32,6 +32,11 @@ const feedbackSchema = z.object({ bundleIdentifier: z.string().trim().max(200).optional().default(""), osVersion: z.string().trim().max(200).optional().default(""), locale: z.string().trim().max(120).optional().default(""), + hardwareModel: z.string().trim().max(120).optional().default(""), + chip: z.string().trim().max(200).optional().default(""), + memoryGB: z.string().trim().max(20).optional().default(""), + architecture: z.string().trim().max(20).optional().default(""), + displayInfo: z.string().trim().max(200).optional().default(""), }); type PreparedAttachment = { @@ -83,6 +88,11 @@ export async function POST(request: Request) { bundleIdentifier: getString(formData, "bundleIdentifier"), osVersion: getString(formData, "osVersion"), locale: getString(formData, "locale"), + hardwareModel: getString(formData, "hardwareModel"), + chip: getString(formData, "chip"), + memoryGB: getString(formData, "memoryGB"), + architecture: getString(formData, "architecture"), + displayInfo: getString(formData, "displayInfo"), }); if (!parsed.success) { @@ -96,8 +106,10 @@ export async function POST(request: Request) { return attachmentsResult.errorResponse; } - const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } = - parsed.data; + const { + appBuild, appCommit, appVersion, architecture, bundleIdentifier, chip, + displayInfo, email, hardwareModel, locale, memoryGB, message, osVersion, + } = parsed.data; const subject = buildSubject(email, message, appVersion); const attachments = attachmentsResult.attachments; const resend = new Resend(feedbackConfig.resendApiKey); @@ -116,6 +128,11 @@ export async function POST(request: Request) { bundleIdentifier, osVersion, locale, + hardwareModel, + chip, + memoryGB, + architecture, + displayInfo, attachments, }), html: buildHtmlBody({ @@ -127,6 +144,11 @@ export async function POST(request: Request) { bundleIdentifier, osVersion, locale, + hardwareModel, + chip, + memoryGB, + architecture, + displayInfo, attachments, }), attachments: attachments.map((attachment) => ({ @@ -241,6 +263,11 @@ function buildTextBody(input: { bundleIdentifier: string; osVersion: string; locale: string; + hardwareModel: string; + chip: string; + memoryGB: string; + architecture: string; + displayInfo: string; attachments: PreparedAttachment[]; }) { const attachmentLines = @@ -262,6 +289,11 @@ function buildTextBody(input: { `Bundle identifier: ${input.bundleIdentifier || "unknown"}`, `macOS: ${input.osVersion || "unknown"}`, `Locale: ${input.locale || "unknown"}`, + `Hardware model: ${input.hardwareModel || "unknown"}`, + `Chip: ${input.chip || "unknown"}`, + `Memory: ${input.memoryGB || "unknown"}`, + `Architecture: ${input.architecture || "unknown"}`, + `Displays: ${input.displayInfo || "unknown"}`, attachmentLines, "", "Message:", @@ -278,6 +310,11 @@ function buildHtmlBody(input: { bundleIdentifier: string; osVersion: string; locale: string; + hardwareModel: string; + chip: string; + memoryGB: string; + architecture: string; + displayInfo: string; attachments: PreparedAttachment[]; }) { const attachmentMarkup = @@ -304,6 +341,11 @@ function buildHtmlBody(input: { )}

    macOS: ${escapeHtml(input.osVersion || "unknown")}

    Locale: ${escapeHtml(input.locale || "unknown")}

    +

    Hardware model: ${escapeHtml(input.hardwareModel || "unknown")}

    +

    Chip: ${escapeHtml(input.chip || "unknown")}

    +

    Memory: ${escapeHtml(input.memoryGB || "unknown")}

    +

    Architecture: ${escapeHtml(input.architecture || "unknown")}

    +

    Displays: ${escapeHtml(input.displayInfo || "unknown")}

    ${attachmentMarkup}

    Message

    ${escapeHtml(
    diff --git a/web/app/robots.ts b/web/app/robots.ts
    index 8cb44e16..1b471bcf 100644
    --- a/web/app/robots.ts
    +++ b/web/app/robots.ts
    @@ -3,6 +3,6 @@ import type { MetadataRoute } from "next";
     export default function robots(): MetadataRoute.Robots {
       return {
         rules: { userAgent: "*", allow: "/" },
    -    sitemap: "https://cmux.dev/sitemap.xml",
    +    sitemap: "https://cmux.com/sitemap.xml",
       };
     }
    diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts
    index dfac9bb5..ecc6e0bb 100644
    --- a/web/app/sitemap.ts
    +++ b/web/app/sitemap.ts
    @@ -2,26 +2,29 @@ import type { MetadataRoute } from "next";
     import { locales } from "../i18n/routing";
     
     export default function sitemap(): MetadataRoute.Sitemap {
    -  const base = "https://cmux.dev";
    +  const base = "https://cmux.com";
     
       const paths = [
    -    { path: "", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 1 },
    -    { path: "/blog", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.8 },
    +    { path: "", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 1 },
    +    { path: "/blog", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.8 },
         { path: "/blog/show-hn-launch", lastModified: "2026-02-21", changeFrequency: "monthly" as const, priority: 0.7 },
         { path: "/blog/introducing-cmux", lastModified: "2026-02-12", changeFrequency: "monthly" as const, priority: 0.7 },
         { path: "/blog/zen-of-cmux", lastModified: "2026-02-27", changeFrequency: "monthly" as const, priority: 0.7 },
         { path: "/blog/cmd-shift-u", lastModified: "2026-03-04", changeFrequency: "monthly" as const, priority: 0.7 },
    -    { path: "/docs/getting-started", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.9 },
    -    { path: "/docs/concepts", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
    -    { path: "/docs/configuration", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
    -    { path: "/docs/keyboard-shortcuts", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.7 },
    -    { path: "/docs/api", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
    -    { path: "/docs/notifications", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
    -    { path: "/docs/changelog", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.5 },
    -    { path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
    -    { path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
    -    { path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
    -    { path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
    +    { path: "/docs/getting-started", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.9 },
    +    { path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
    +    { path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
    +    { path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 },
    +    { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
    +    { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
    +    { path: "/docs/changelog", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.5 },
    +    { path: "/docs/browser-automation", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 },
    +    { path: "/community", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.5 },
    +    { path: "/wall-of-love", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.5 },
    +    { path: "/nightly", lastModified: "2026-03-18", changeFrequency: "weekly" as const, priority: 0.6 },
    +    { path: "/privacy-policy", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
    +    { path: "/terms-of-service", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
    +    { path: "/eula", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 },
       ];
     
       const entries: MetadataRoute.Sitemap = [];
    @@ -32,6 +35,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
           alternates[locale] =
             locale === "en" ? `${base}${path}` : `${base}/${locale}${path}`;
         }
    +    alternates["x-default"] = `${base}${path}`;
     
         entries.push({
           url: `${base}${path}`,
    diff --git a/web/proxy.ts b/web/proxy.ts
    index fcf488a6..547c423b 100644
    --- a/web/proxy.ts
    +++ b/web/proxy.ts
    @@ -1,7 +1,22 @@
    +import { type NextRequest, NextResponse } from "next/server";
     import createMiddleware from "next-intl/middleware";
     import { routing } from "./i18n/routing";
     
    -export default createMiddleware(routing);
    +const intlMiddleware = createMiddleware(routing);
    +
    +export default function middleware(request: NextRequest) {
    +  const host = request.headers.get("host") ?? "";
    +
    +  // 301 redirect cmux.dev (and www.cmux.dev) to cmux.com, preserving path and query
    +  if (host === "cmux.dev" || host === "www.cmux.dev") {
    +    const url = new URL(request.url);
    +    url.host = "cmux.com";
    +    url.protocol = "https:";
    +    return NextResponse.redirect(url.toString(), 301);
    +  }
    +
    +  return intlMiddleware(request);
    +}
     
     export const config = {
       matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],