Split 16k-line mega test file, bump CI timeout, stream xcodebuild output (#1717)
CmuxWebViewKeyEquivalentTests.swift grew to 15,907 lines with 100+ test classes. Swift compiles per-file, so this single file serialized all type-checking onto one compiler process, pushing CI past the 20-minute timeout after core-file changes. Split into 10 domain-based files (1k-3k lines each) so Xcode can compile them in parallel. Also bump timeout-minutes from 20 to 30 for headroom, stream xcodebuild output via tee instead of capturing to a variable (makes CI logs debuggable), and add 5 test files that were missing from the pbxproj Sources build phase. Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
33d21ea19e
commit
ac83af62ae
13 changed files with 16227 additions and 15919 deletions
15
.github/workflows/ci-macos-compat.yml
vendored
15
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -133,8 +133,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 +146,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
|
||||
|
|
|
|||
|
|
@ -86,7 +86,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 +102,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 */ = {
|
||||
|
|
@ -236,7 +250,6 @@
|
|||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -253,6 +266,21 @@
|
|||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
|
||||
970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = "<group>"; };
|
||||
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = "<group>"; };
|
||||
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = "<group>"; };
|
||||
71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = "<group>"; };
|
||||
BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = "<group>"; };
|
||||
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = "<group>"; };
|
||||
BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = "<group>"; };
|
||||
B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = "<group>"; };
|
||||
D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = "<group>"; };
|
||||
42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = "<group>"; };
|
||||
14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = "<group>"; };
|
||||
1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = "<group>"; };
|
||||
EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = "<group>"; };
|
||||
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = "<group>"; };
|
||||
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -475,7 +503,6 @@
|
|||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
|
|
@ -489,6 +516,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 = "<group>";
|
||||
|
|
@ -719,7 +761,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 */,
|
||||
|
|
|
|||
3108
cmuxTests/BrowserConfigTests.swift
Normal file
3108
cmuxTests/BrowserConfigTests.swift
Normal file
File diff suppressed because it is too large
Load diff
2935
cmuxTests/BrowserPanelTests.swift
Normal file
2935
cmuxTests/BrowserPanelTests.swift
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
832
cmuxTests/NotificationAndMenuBarTests.swift
Normal file
832
cmuxTests/NotificationAndMenuBarTests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
857
cmuxTests/OmnibarAndToolsTests.swift
Normal file
857
cmuxTests/OmnibarAndToolsTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
965
cmuxTests/ShortcutAndCommandPaletteTests.swift
Normal file
965
cmuxTests/ShortcutAndCommandPaletteTests.swift
Normal file
|
|
@ -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<CGFloat>] = [0...20, 28...40, 48...64]
|
||||
XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0])
|
||||
}
|
||||
|
||||
func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() {
|
||||
let intervals: [ClosedRange<CGFloat>] = [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<CGFloat>] = [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<CGFloat>] = [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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
941
cmuxTests/SidebarOrderingTests.swift
Normal file
941
cmuxTests/SidebarOrderingTests.swift
Normal file
|
|
@ -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<UUID> = [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<UUID> = [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"
|
||||
)
|
||||
}
|
||||
}
|
||||
976
cmuxTests/TabManagerUnitTests.swift
Normal file
976
cmuxTests/TabManagerUnitTests.swift
Normal file
|
|
@ -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>) -> 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)
|
||||
}
|
||||
}
|
||||
2578
cmuxTests/TerminalAndGhosttyTests.swift
Normal file
2578
cmuxTests/TerminalAndGhosttyTests.swift
Normal file
File diff suppressed because it is too large
Load diff
1082
cmuxTests/WindowAndDragTests.swift
Normal file
1082
cmuxTests/WindowAndDragTests.swift
Normal file
File diff suppressed because it is too large
Load diff
1899
cmuxTests/WorkspaceUnitTests.swift
Normal file
1899
cmuxTests/WorkspaceUnitTests.swift
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue