import XCTest import AppKit import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif final class SidebarPathFormatterTests: XCTestCase { func testShortenedPathReplacesExactHomeDirectory() { XCTAssertEqual( SidebarPathFormatter.shortenedPath( "/Users/example", homeDirectoryPath: "/Users/example" ), "~" ) } func testShortenedPathReplacesHomeDirectoryPrefix() { XCTAssertEqual( SidebarPathFormatter.shortenedPath( "/Users/example/projects/cmux", homeDirectoryPath: "/Users/example" ), "~/projects/cmux" ) } func testShortenedPathLeavesExternalPathUnchanged() { XCTAssertEqual( SidebarPathFormatter.shortenedPath( "/tmp/cmux", homeDirectoryPath: "/Users/example" ), "/tmp/cmux" ) } } final class GhosttyConfigTests: XCTestCase { private struct RGB: Equatable { let red: Int let green: Int let blue: Int } func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", preferredColorScheme: .light ) XCTAssertEqual(resolved, "Builtin Solarized Light") } func testResolveThemeNamePrefersDarkEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", preferredColorScheme: .dark ) XCTAssertEqual(resolved, "Builtin Solarized Dark") } func testThemeNameCandidatesIncludeBuiltinAliasForms() { let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Light") XCTAssertEqual(candidates.first, "Builtin Solarized Light") XCTAssertTrue(candidates.contains("Solarized Light")) XCTAssertTrue(candidates.contains("iTerm2 Solarized Light")) } func testThemeNameCandidatesMapSolarizedDarkToITerm2Alias() { let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Dark") XCTAssertTrue(candidates.contains("Solarized Dark")) XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark")) } func testThemeSearchPathsIncludeXDGDataDirsThemes() { let pathA = "/tmp/cmux-theme-a" let pathB = "/tmp/cmux-theme-b" let paths = GhosttyConfig.themeSearchPaths( forThemeName: "Solarized Light", environment: ["XDG_DATA_DIRS": "\(pathA):\(pathB)"], bundleResourceURL: nil ) XCTAssertTrue(paths.contains("\(pathA)/ghostty/themes/Solarized Light")) XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light")) } func testLoadThemeResolvesPairedThemeValueByColorScheme() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)") let themesDir = root.appendingPathComponent("themes") try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } try """ background = #fdf6e3 foreground = #657b83 """.write( to: themesDir.appendingPathComponent("Light Theme"), atomically: true, encoding: .utf8 ) try """ background = #002b36 foreground = #93a1a1 """.write( to: themesDir.appendingPathComponent("Dark Theme"), atomically: true, encoding: .utf8 ) var lightConfig = GhosttyConfig() lightConfig.loadTheme( "light:Light Theme,dark:Dark Theme", environment: ["GHOSTTY_RESOURCES_DIR": root.path], bundleResourceURL: nil, preferredColorScheme: .light ) XCTAssertEqual(rgb255(lightConfig.backgroundColor), RGB(red: 253, green: 246, blue: 227)) var darkConfig = GhosttyConfig() darkConfig.loadTheme( "light:Light Theme,dark:Dark Theme", environment: ["GHOSTTY_RESOURCES_DIR": root.path], bundleResourceURL: nil, preferredColorScheme: .dark ) XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54)) } func testParseBackgroundOpacityReadsConfigValue() { var config = GhosttyConfig() config.parse("background-opacity = 0.42") XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001) } func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)") let themesDir = root.appendingPathComponent("themes") try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let themePath = themesDir.appendingPathComponent("Solarized Light") let themeContents = """ background = #fdf6e3 foreground = #657b83 """ try themeContents.write(to: themePath, atomically: true, encoding: .utf8) var config = GhosttyConfig() config.loadTheme( "Builtin Solarized Light", environment: ["GHOSTTY_RESOURCES_DIR": root.path], bundleResourceURL: nil ) XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227)) } func testLoadCachesPerColorScheme() { GhosttyConfig.invalidateLoadCache() defer { GhosttyConfig.invalidateLoadCache() } var loadCount = 0 let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in loadCount += 1 var config = GhosttyConfig() config.fontFamily = "\(scheme)-\(loadCount)" return config } let lightFirst = GhosttyConfig.load( preferredColorScheme: .light, loadFromDisk: loadFromDisk ) let lightSecond = GhosttyConfig.load( preferredColorScheme: .light, loadFromDisk: loadFromDisk ) let darkFirst = GhosttyConfig.load( preferredColorScheme: .dark, loadFromDisk: loadFromDisk ) XCTAssertEqual(loadCount, 2) XCTAssertEqual(lightFirst.fontFamily, "light-1") XCTAssertEqual(lightSecond.fontFamily, "light-1") XCTAssertEqual(darkFirst.fontFamily, "dark-2") } func testLoadCacheInvalidationForcesReload() { GhosttyConfig.invalidateLoadCache() defer { GhosttyConfig.invalidateLoadCache() } var loadCount = 0 let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in loadCount += 1 var config = GhosttyConfig() config.fontFamily = "reload-\(loadCount)" return config } let first = GhosttyConfig.load( preferredColorScheme: .dark, loadFromDisk: loadFromDisk ) GhosttyConfig.invalidateLoadCache() let second = GhosttyConfig.load( preferredColorScheme: .dark, loadFromDisk: loadFromDisk ) XCTAssertEqual(loadCount, 2) XCTAssertEqual(first.fontFamily, "reload-1") XCTAssertEqual(second.fontFamily, "reload-2") } func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() { XCTAssertTrue( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: 0, legacyConfigFileSize: 42 ) ) } func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() { XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: nil, legacyConfigFileSize: 42 ) ) XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: 10, legacyConfigFileSize: 42 ) ) XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: 0, legacyConfigFileSize: 0 ) ) XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: 0, legacyConfigFileSize: nil ) ) } func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() { XCTAssertTrue( GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: "com.cmuxterm.app.debug", currentConfigFileSize: nil, currentLegacyConfigFileSize: nil, releaseConfigFileSize: 128, releaseLegacyConfigFileSize: nil ) ) } func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() { XCTAssertFalse( GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829", currentConfigFileSize: nil, currentLegacyConfigFileSize: 64, releaseConfigFileSize: 128, releaseLegacyConfigFileSize: nil ) ) } func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() { XCTAssertFalse( GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: "com.cmuxterm.app", currentConfigFileSize: nil, currentLegacyConfigFileSize: nil, releaseConfigFileSize: 128, releaseLegacyConfigFileSize: nil ) ) XCTAssertFalse( GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: "com.cmuxterm.app.debug", currentConfigFileSize: nil, currentLegacyConfigFileSize: nil, releaseConfigFileSize: nil, releaseLegacyConfigFileSize: 0 ) ) } func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { XCTAssertTrue( GhosttyApp.shouldApplyDefaultBackgroundUpdate( currentScope: .unscoped, incomingScope: .app ) ) XCTAssertTrue( GhosttyApp.shouldApplyDefaultBackgroundUpdate( currentScope: .app, incomingScope: .surface ) ) XCTAssertTrue( GhosttyApp.shouldApplyDefaultBackgroundUpdate( currentScope: .surface, incomingScope: .surface ) ) XCTAssertFalse( GhosttyApp.shouldApplyDefaultBackgroundUpdate( currentScope: .surface, incomingScope: .app ) ) XCTAssertFalse( GhosttyApp.shouldApplyDefaultBackgroundUpdate( currentScope: .surface, incomingScope: .unscoped ) ) } func testAppearanceChangeReloadsWhenColorSchemeChanges() { XCTAssertTrue( GhosttyApp.shouldReloadConfigurationForAppearanceChange( previousColorScheme: .dark, currentColorScheme: .light ) ) XCTAssertTrue( GhosttyApp.shouldReloadConfigurationForAppearanceChange( previousColorScheme: nil, currentColorScheme: .dark ) ) } func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() { XCTAssertFalse( GhosttyApp.shouldReloadConfigurationForAppearanceChange( previousColorScheme: .light, currentColorScheme: .light ) ) XCTAssertFalse( GhosttyApp.shouldReloadConfigurationForAppearanceChange( previousColorScheme: .dark, currentColorScheme: .dark ) ) } func testScrollLagCaptureRequiresSustainedLag() { XCTAssertFalse( GhosttyApp.shouldCaptureScrollLagEvent( samples: 4, averageMs: 18, maxMs: 85, thresholdMs: 40, nowUptime: 1000, lastReportedUptime: nil ) ) XCTAssertFalse( GhosttyApp.shouldCaptureScrollLagEvent( samples: 10, averageMs: 6, maxMs: 85, thresholdMs: 40, nowUptime: 1000, lastReportedUptime: nil ) ) XCTAssertFalse( GhosttyApp.shouldCaptureScrollLagEvent( samples: 10, averageMs: 18, maxMs: 35, thresholdMs: 40, nowUptime: 1000, lastReportedUptime: nil ) ) XCTAssertTrue( GhosttyApp.shouldCaptureScrollLagEvent( samples: 10, averageMs: 18, maxMs: 85, thresholdMs: 40, nowUptime: 1000, lastReportedUptime: nil ) ) } func testScrollLagCaptureRespectsCooldownWindow() { XCTAssertFalse( GhosttyApp.shouldCaptureScrollLagEvent( samples: 12, averageMs: 22, maxMs: 90, thresholdMs: 40, nowUptime: 1200, lastReportedUptime: 1005, cooldown: 300 ) ) XCTAssertTrue( GhosttyApp.shouldCaptureScrollLagEvent( samples: 12, averageMs: 22, maxMs: 90, thresholdMs: 40, nowUptime: 1406, lastReportedUptime: 1005, cooldown: 300 ) ) } func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated user defaults suite") return } defer { defaults.removePersistentDomain(forName: suiteName) } defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } func testClaudeCodeIntegrationRespectsStoredPreference() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated user defaults suite") return } defer { defaults.removePersistentDomain(forName: suiteName) } defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } func testTelemetryDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated user defaults suite") return } defer { defaults.removePersistentDomain(forName: suiteName) } defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey) XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults)) } func testTelemetryRespectsStoredPreference() { let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated user defaults suite") return } defer { defaults.removePersistentDomain(forName: suiteName) } defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey) XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults)) defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey) XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults)) } private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) return RGB( red: Int(round(red * 255)), green: Int(round(green * 255)), blue: Int(round(blue * 255)) ) } } final class WorkspaceChromeThemeTests: XCTestCase { func testResolvedChromeColorsUsesLightGhosttyBackground() { guard let backgroundColor = NSColor(hex: "#FDF6E3") else { XCTFail("Expected valid test color") return } let colors = Workspace.resolvedChromeColors(from: backgroundColor) XCTAssertEqual(colors.backgroundHex, "#FDF6E3") XCTAssertNil(colors.borderHex) } func testResolvedChromeColorsUsesDarkGhosttyBackground() { guard let backgroundColor = NSColor(hex: "#272822") else { XCTFail("Expected valid test color") return } let colors = Workspace.resolvedChromeColors(from: backgroundColor) XCTAssertEqual(colors.backgroundHex, "#272822") XCTAssertNil(colors.borderHex) } } final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() { guard let loadedBackground = NSColor(hex: "#112233"), let runtimeBackground = NSColor(hex: "#FDF6E3"), let loadedForeground = NSColor(hex: "#ABCDEF") else { XCTFail("Expected valid test colors") return } var loaded = GhosttyConfig() loaded.backgroundColor = loadedBackground loaded.foregroundColor = loadedForeground loaded.unfocusedSplitOpacity = 0.42 let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( loadConfig: { loaded }, defaultBackground: { runtimeBackground } ) XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3") XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF") XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001) } func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() { guard let loadedBackground = NSColor(hex: "#112233"), let runtimeBackground = NSColor(hex: "#FDF6E3"), let explicitOverride = NSColor(hex: "#272822") else { XCTFail("Expected valid test colors") return } var loaded = GhosttyConfig() loaded.backgroundColor = loadedBackground let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( backgroundOverride: explicitOverride, loadConfig: { loaded }, defaultBackground: { runtimeBackground } ) XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822") } } @MainActor final class WorkspaceChromeColorTests: XCTestCase { func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, blue: 51.0 / 255.0, alpha: 1.0 ) let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) XCTAssertEqual(hex, "#1122337F") } func testBonsplitChromeHexOmitsAlphaWhenOpaque() { let color = NSColor( srgbRed: 17.0 / 255.0, green: 34.0 / 255.0, blue: 51.0 / 255.0, alpha: 1.0 ) let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) XCTAssertEqual(hex, "#112233") } } final class WindowTransparencyDecisionTests: XCTestCase { private let sidebarBlendModeKey = "sidebarBlendMode" private let bgGlassEnabledKey = "bgGlassEnabled" func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() { withTemporaryWindowBackgroundDefaults { let defaults = UserDefaults.standard defaults.set("withinWindow", forKey: sidebarBlendModeKey) defaults.set(false, forKey: bgGlassEnabledKey) XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow()) XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80)) XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0)) } } func testBehindWindowGlassPathStillControlsTransparentWindowFallback() { withTemporaryWindowBackgroundDefaults { let defaults = UserDefaults.standard defaults.set("behindWindow", forKey: sidebarBlendModeKey) defaults.set(true, forKey: bgGlassEnabledKey) let expectedTransparentFallback = !WindowGlassEffect.isAvailable XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback) XCTAssertEqual( cmuxShouldUseClearWindowBackground(for: 1.0), expectedTransparentFallback ) } } private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) { let defaults = UserDefaults.standard let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey) let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey) defer { restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults) restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults) } body() } private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } } final class WorkspaceRemoteDaemonManifestTests: XCTestCase { func testParsesEmbeddedRemoteDaemonManifestJSON() throws { let manifestJSON = """ { "schemaVersion": 1, "appVersion": "0.62.0", "releaseTag": "v0.62.0", "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", "checksumsAssetName": "cmuxd-remote-checksums.txt", "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt", "entries": [ { "goOS": "linux", "goArch": "amd64", "assetName": "cmuxd-remote-linux-amd64", "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64", "sha256": "abc123" } ] } """ let manifest = Workspace.remoteDaemonManifest(from: [ Workspace.remoteDaemonManifestInfoKey: manifestJSON, ]) XCTAssertEqual(manifest?.releaseTag, "v0.62.0") XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64") } func testRemoteDaemonCachePathIsVersionedByPlatform() throws { let url = try Workspace.remoteDaemonCachedBinaryURL( version: "0.62.0", goOS: "linux", goArch: "arm64" ) XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/")) XCTAssertEqual(url.lastPathComponent, "cmuxd-remote") } } final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { func testRewritesLoopbackAliasHostHeadersToLocalhost() { let original = Data( ( "GET /demo HTTP/1.1\r\n" + "Host: cmux-loopback.localtest.me:3000\r\n" + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + "\r\n" ).utf8 ) let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( data: original, aliasHost: "cmux-loopback.localtest.me" ) let text = String(decoding: rewritten, as: UTF8.self) XCTAssertTrue(text.contains("Host: localhost:3000")) XCTAssertTrue(text.contains("Origin: http://localhost:3000")) XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) } func testRewritesAbsoluteFormRequestLineForLoopbackAlias() { let original = Data( ( "GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" + "Host: cmux-loopback.localtest.me:3000\r\n" + "\r\n" ).utf8 ) let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( data: original, aliasHost: "cmux-loopback.localtest.me" ) let text = String(decoding: rewritten, as: UTF8.self) XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n")) XCTAssertTrue(text.contains("Host: localhost:3000")) } func testLeavesNonHTTPPayloadUntouched() { let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00]) let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( data: original, aliasHost: "cmux-loopback.localtest.me" ) XCTAssertEqual(rewritten, original) } func testRewritesLoopbackResponseHeadersBackToAlias() { let original = Data( ( "HTTP/1.1 302 Found\r\n" + "Location: http://localhost:3000/login\r\n" + "Access-Control-Allow-Origin: http://localhost:3000\r\n" + "Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" + "\r\n" ).utf8 ) let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( data: original, aliasHost: "cmux-loopback.localtest.me" ) let text = String(decoding: rewritten, as: UTF8.self) XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login")) XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000")) XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/")) } } @MainActor final class BrowserPanelRemoteStoreTests: XCTestCase { func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() { let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true) XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) } } final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { func testSupportsMultiplePendingCallsResolvedOutOfOrder() { let registry = WorkspaceRemoteDaemonPendingCallRegistry() let first = registry.register() let second = registry.register() XCTAssertTrue(registry.resolve(id: second.id, payload: [ "ok": true, "result": ["stream_id": "second"], ])) switch registry.wait(for: second, timeout: 0.1) { case .response(let response): XCTAssertEqual(response["ok"] as? Bool, true) XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second") default: XCTFail("second pending call should complete independently") } XCTAssertTrue(registry.resolve(id: first.id, payload: [ "ok": true, "result": ["stream_id": "first"], ])) switch registry.wait(for: first, timeout: 0.1) { case .response(let response): XCTAssertEqual(response["ok"] as? Bool, true) XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first") default: XCTFail("first pending call should remain pending until its own response arrives") } } func testFailAllSignalsEveryPendingCall() { let registry = WorkspaceRemoteDaemonPendingCallRegistry() let first = registry.register() let second = registry.register() registry.failAll("daemon transport stopped") switch registry.wait(for: first, timeout: 0.1) { case .failure(let message): XCTAssertEqual(message, "daemon transport stopped") default: XCTFail("first pending call should receive shared failure") } switch registry.wait(for: second, timeout: 0.1) { case .failure(let message): XCTAssertEqual(message, "daemon transport stopped") default: XCTFail("second pending call should receive shared failure") } } } final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() let activeSelectedTabId = UUID() XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: true, owningSelectedTabId: tabId, activeSelectedTabId: activeSelectedTabId ) ) } func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() { let tabId = UUID() XCTAssertFalse( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: true, owningSelectedTabId: UUID(), activeSelectedTabId: tabId ) ) } func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() { let tabId = UUID() XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: true, owningSelectedTabId: nil, activeSelectedTabId: UUID() ) ) } func testShouldApplyWindowBackgroundFallsBackToActiveSelection() { let tabId = UUID() XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: false, owningSelectedTabId: nil, activeSelectedTabId: tabId ) ) XCTAssertFalse( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: tabId, owningManagerExists: false, owningSelectedTabId: nil, activeSelectedTabId: UUID() ) ) } func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() { XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: UUID(), owningManagerExists: false, owningSelectedTabId: nil, activeSelectedTabId: nil ) ) XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: nil, owningManagerExists: false, owningSelectedTabId: nil, activeSelectedTabId: nil ) ) XCTAssertTrue( GhosttyNSView.shouldApplyWindowBackground( surfaceTabId: nil, owningManagerExists: true, owningSelectedTabId: UUID(), activeSelectedTabId: UUID() ) ) } } final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) let expectation = expectation(description: "flush once") expectation.expectedFulfillmentCount = 1 var flushCount = 0 DispatchQueue.main.async { for _ in 0..<8 { coalescer.signal { flushCount += 1 expectation.fulfill() } } } wait(for: [expectation], timeout: 1.0) XCTAssertEqual(flushCount, 1) } func testLatestActionWinsWithinBurst() { let coalescer = NotificationBurstCoalescer(delay: 0.01) let expectation = expectation(description: "latest action flushed") var value = 0 DispatchQueue.main.async { coalescer.signal { value = 1 } coalescer.signal { value = 2 expectation.fulfill() } } wait(for: [expectation], timeout: 1.0) XCTAssertEqual(value, 2) } func testSignalsAcrossBurstsFlushMultipleTimes() { let coalescer = NotificationBurstCoalescer(delay: 0.01) let expectation = expectation(description: "flush twice") expectation.expectedFulfillmentCount = 2 var flushCount = 0 DispatchQueue.main.async { coalescer.signal { flushCount += 1 expectation.fulfill() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { coalescer.signal { flushCount += 1 expectation.fulfill() } } } wait(for: [expectation], timeout: 1.0) XCTAssertEqual(flushCount, 2) } } final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { func testSignalCoalescesBurstToLatestBackground() { guard let dark = NSColor(hex: "#272822"), let light = NSColor(hex: "#FDF6E3") else { XCTFail("Expected valid test colors") return } let expectation = expectation(description: "coalesced notification") expectation.expectedFulfillmentCount = 1 var postedUserInfos: [[AnyHashable: Any]] = [] let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( delay: 0.01, postNotification: { userInfo in postedUserInfos.append(userInfo) expectation.fulfill() } ) DispatchQueue.main.async { dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark") dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light") } wait(for: [expectation], timeout: 1.0) XCTAssertEqual(postedUserInfos.count, 1) XCTAssertEqual( (postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(), "#FDF6E3" ) XCTAssertEqual( postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]), 0.75, accuracy: 0.0001 ) XCTAssertEqual( (postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value, 2 ) XCTAssertEqual( postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String, "test.light" ) } func testSignalAcrossSeparateBurstsPostsMultipleNotifications() { guard let dark = NSColor(hex: "#272822"), let light = NSColor(hex: "#FDF6E3") else { XCTFail("Expected valid test colors") return } let expectation = expectation(description: "two notifications") expectation.expectedFulfillmentCount = 2 var postedHexes: [String] = [] let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( delay: 0.01, postNotification: { userInfo in let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" postedHexes.append(hex) expectation.fulfill() } ) DispatchQueue.main.async { dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark") DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light") } } wait(for: [expectation], timeout: 1.0) XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"]) } private func postedOpacity(from value: Any?) -> Double { if let value = value as? Double { return value } if let value = value as? NSNumber { return value.doubleValue } XCTFail("Expected background opacity payload") return -1 } } final class RecentlyClosedBrowserStackTests: XCTestCase { func testPopReturnsEntriesInLIFOOrder() { var stack = RecentlyClosedBrowserStack(capacity: 20) stack.push(makeSnapshot(index: 1)) stack.push(makeSnapshot(index: 2)) stack.push(makeSnapshot(index: 3)) XCTAssertEqual(stack.pop()?.originalTabIndex, 3) XCTAssertEqual(stack.pop()?.originalTabIndex, 2) XCTAssertEqual(stack.pop()?.originalTabIndex, 1) XCTAssertNil(stack.pop()) } func testPushDropsOldestEntriesWhenCapacityExceeded() { var stack = RecentlyClosedBrowserStack(capacity: 3) for index in 1...5 { stack.push(makeSnapshot(index: index)) } XCTAssertEqual(stack.pop()?.originalTabIndex, 5) XCTAssertEqual(stack.pop()?.originalTabIndex, 4) XCTAssertEqual(stack.pop()?.originalTabIndex, 3) XCTAssertNil(stack.pop()) } private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot { ClosedBrowserPanelRestoreSnapshot( workspaceId: UUID(), url: URL(string: "https://example.com/\(index)"), originalPaneId: UUID(), originalTabIndex: index, fallbackSplitOrientation: .horizontal, fallbackSplitInsertFirst: false, fallbackAnchorPaneId: UUID() ) } } final class SocketControlSettingsTests: XCTestCase { func testMigrateModeSupportsExpandedSocketModes() { XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off) XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly) XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation) XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password) XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll) // Legacy aliases XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation) XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll) } func testSocketModePermissions() { XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600) XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600) XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600) XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600) XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666) } func testInvalidEnvSocketModeDoesNotOverrideUserMode() { XCTAssertNil( SocketControlSettings.envOverrideMode( environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] ) ) XCTAssertEqual( SocketControlSettings.effectiveMode( userMode: .password, environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] ), .password ) } func testStableReleaseIgnoresAmbientSocketOverrideByDefault() { let path = SocketControlSettings.socketPath( environment: [ "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", ], bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false ) XCTAssertEqual(path, "/tmp/cmux.sock") } func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() { let path = SocketControlSettings.socketPath( environment: [ "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", ], bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false ) XCTAssertEqual(path, "/tmp/cmux-nightly.sock") } func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() { let path = SocketControlSettings.socketPath( environment: [ "CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock", ], bundleIdentifier: "com.cmuxterm.app.debug.my-tag", isDebugBuild: false ) XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock") } func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() { let path = SocketControlSettings.socketPath( environment: [ "CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock", ], bundleIdentifier: "com.cmuxterm.app.staging.my-tag", isDebugBuild: false ) XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock") } func testStableReleaseCanOptInToSocketOverride() { let path = SocketControlSettings.socketPath( environment: [ "CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock", "CMUX_ALLOW_SOCKET_OVERRIDE": "1", ], bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false ) XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock") } func testDefaultSocketPathByChannel() { XCTAssertEqual( SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false), "/tmp/cmux.sock" ) XCTAssertEqual( SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false), "/tmp/cmux-nightly.sock" ) XCTAssertEqual( SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false), "/tmp/cmux-debug.sock" ) XCTAssertEqual( SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false), "/tmp/cmux-staging.sock" ) } func testUntaggedDebugBundleBlockedWithoutLaunchTag() { XCTAssertTrue( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: [:], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } func testUntaggedDebugBundleAllowedWithLaunchTag() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: ["CMUX_TAG": "tests-v1"], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } func testTaggedDebugBundleAllowedWithoutLaunchTag() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: [:], bundleIdentifier: "com.cmuxterm.app.debug.tests-v1", isDebugBuild: true ) ) } func testReleaseBuildIgnoresLaunchTagGate() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: [:], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: false ) ) } func testXCTestLaunchIgnoresLaunchTagGate() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: ["XCInjectBundle": "/tmp/fake.xctest"], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } func testXCTestDyldLaunchIgnoresLaunchTagGate() { XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() { // XCUITest launches the app as a separate process without XCTest env vars. // The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment. XCTAssertFalse( SocketControlSettings.shouldBlockUntaggedDebugLaunch( environment: ["CMUX_UI_TEST_MODE": "1"], bundleIdentifier: "com.cmuxterm.app.debug", isDebugBuild: true ) ) } } final class PostHogAnalyticsPropertiesTests: XCTestCase { func testDailyActivePropertiesIncludeVersionAndBuild() { let properties = PostHogAnalytics.dailyActiveProperties( dayUTC: "2026-02-21", reason: "didBecomeActive", infoDictionary: [ "CFBundleShortVersionString": "0.31.0", "CFBundleVersion": "230", ] ) XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21") XCTAssertEqual(properties["reason"] as? String, "didBecomeActive") XCTAssertEqual(properties["app_version"] as? String, "0.31.0") XCTAssertEqual(properties["app_build"] as? String, "230") } func testSuperPropertiesIncludePlatformVersionAndBuild() { let properties = PostHogAnalytics.superProperties( infoDictionary: [ "CFBundleShortVersionString": "0.31.0", "CFBundleVersion": "230", ] ) XCTAssertEqual(properties["platform"] as? String, "cmuxterm") XCTAssertEqual(properties["app_version"] as? String, "0.31.0") XCTAssertEqual(properties["app_build"] as? String, "230") } func testHourlyActivePropertiesIncludeVersionAndBuild() { let properties = PostHogAnalytics.hourlyActiveProperties( hourUTC: "2026-02-21T14", reason: "didBecomeActive", infoDictionary: [ "CFBundleShortVersionString": "0.31.0", "CFBundleVersion": "230", ] ) XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14") XCTAssertEqual(properties["reason"] as? String, "didBecomeActive") XCTAssertEqual(properties["app_version"] as? String, "0.31.0") XCTAssertEqual(properties["app_build"] as? String, "230") } func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() { let properties = PostHogAnalytics.hourlyActiveProperties( hourUTC: "2026-02-21T14", reason: "activeTimer", infoDictionary: [:] ) XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14") XCTAssertEqual(properties["reason"] as? String, "activeTimer") XCTAssertNil(properties["app_version"]) XCTAssertNil(properties["app_build"]) } func testPropertiesOmitVersionFieldsWhenUnavailable() { let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:]) XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm") XCTAssertNil(superProperties["app_version"]) XCTAssertNil(superProperties["app_build"]) let dailyProperties = PostHogAnalytics.dailyActiveProperties( dayUTC: "2026-02-21", reason: "activeTimer", infoDictionary: [:] ) XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21") XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer") XCTAssertNil(dailyProperties["app_version"]) XCTAssertNil(dailyProperties["app_build"]) } func testFlushPolicyIncludesDailyAndHourlyActiveEvents() { XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active")) XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active")) XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event")) } } final class GhosttyMouseFocusTests: XCTestCase { func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() { XCTAssertTrue( GhosttyNSView.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: true, pressedMouseButtons: 0, appIsActive: true, windowIsKey: true, alreadyFirstResponder: false, visibleInUI: true, hasUsableGeometry: true, hiddenInHierarchy: false ) ) } func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() { XCTAssertFalse( GhosttyNSView.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: false, pressedMouseButtons: 0, appIsActive: true, windowIsKey: true, alreadyFirstResponder: false, visibleInUI: true, hasUsableGeometry: true, hiddenInHierarchy: false ) ) } func testShouldNotRequestFirstResponderDuringMouseDrag() { XCTAssertFalse( GhosttyNSView.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: true, pressedMouseButtons: 1, appIsActive: true, windowIsKey: true, alreadyFirstResponder: false, visibleInUI: true, hasUsableGeometry: true, hiddenInHierarchy: false ) ) } func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() { XCTAssertFalse( GhosttyNSView.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: true, pressedMouseButtons: 0, appIsActive: true, windowIsKey: true, alreadyFirstResponder: false, visibleInUI: true, hasUsableGeometry: false, hiddenInHierarchy: false ) ) XCTAssertFalse( GhosttyNSView.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: true, pressedMouseButtons: 0, appIsActive: true, windowIsKey: true, alreadyFirstResponder: false, visibleInUI: true, hasUsableGeometry: true, hiddenInHierarchy: true ) ) } // MARK: - CJK Font Fallback private func withTempConfig( _ contents: String, body: (String) -> Void ) throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let file = dir.appendingPathComponent("config") try contents.write(to: file, atomically: true, encoding: .utf8) body(file.path) } // MARK: cjkFontMappings func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() { let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])! let fonts = Set(mappings.map(\.1)) let ranges = mappings.map(\.0) XCTAssertTrue(fonts.contains("Hiragino Sans")) XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana") XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana") XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul") } func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() { let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])! let fonts = Set(mappings.map(\.1)) let ranges = mappings.map(\.0) XCTAssertTrue(fonts.contains("Apple SD Gothic Neo")) XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables") XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo") XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana") } func testCJKFontMappingsReturnsPingFangForChinese() { let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])! XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" }) let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])! XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" }) let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])! XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" }) } func testCJKFontMappingsReturnsNilForNonCJKLanguages() { XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"])) XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: [])) } func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() { let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])! let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0) let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0) XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino") XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font") XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo") XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino") } // MARK: userConfigContainsCJKCodepointMap func testUserConfigContainsCJKCodepointMapDetectsPresence() throws { try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) } } func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws { try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) } } func testUserConfigContainsCJKCodepointMapIgnoresComments() throws { try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) } } func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config" XCTAssertFalse( GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]) ) } func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let included = dir.appendingPathComponent("fonts.conf") try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" .write(to: included, atomically: true, encoding: .utf8) let main = dir.appendingPathComponent("config") try "font-family = Menlo\nconfig-file = \(included.path)\n" .write(to: main, atomically: true, encoding: .utf8) XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) } func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let included = dir.appendingPathComponent("fonts.conf") try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" .write(to: included, atomically: true, encoding: .utf8) let main = dir.appendingPathComponent("config") try "config-file = fonts.conf\n" .write(to: main, atomically: true, encoding: .utf8) XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) } func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let included = dir.appendingPathComponent("fonts.conf") try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" .write(to: included, atomically: true, encoding: .utf8) let main = dir.appendingPathComponent("config") try "config-file = \(included.path)?\n" .write(to: main, atomically: true, encoding: .utf8) XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) } func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let fileA = dir.appendingPathComponent("a.conf") let fileB = dir.appendingPathComponent("b.conf") try "config-file = \(fileB.path)\n" .write(to: fileA, atomically: true, encoding: .utf8) try "config-file = \(fileA.path)\n" .write(to: fileB, atomically: true, encoding: .utf8) // Should not hang; should return false since neither file has font-codepoint-map XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) } } final class ZshShellIntegrationHandoffTests: XCTestCase { func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) XCTAssertTrue(output.contains("PRECMD=1"), output) XCTAssertTrue(output.contains("PREEXEC=1"), output) XCTAssertTrue(output.contains("PRECMDS=_ghostty_precmd"), output) } func testGhosttyPromptHooksDoNotLoadWithoutCmuxHandoffFlag() throws { let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: false) XCTAssertTrue(output.contains("PRECMD=0"), output) XCTAssertTrue(output.contains("PREEXEC=0"), output) } private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } let userZdotdir = root.appendingPathComponent("zdotdir") try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration") let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src") let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = [ "-i", "-c", "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" ] process.environment = [ "HOME": root.path, "TERM": "xterm-256color", "SHELL": "/bin/zsh", "USER": NSUserName(), "ZDOTDIR": cmuxZdotdir.path, "CMUX_ZSH_ZDOTDIR": userZdotdir.path, "CMUX_SHELL_INTEGRATION": "0", "GHOSTTY_RESOURCES_DIR": ghosttyResources.path, ] if cmuxLoadGhosttyIntegration { process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" } let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr try process.run() let deadline = Date().addingTimeInterval(5) while process.isRunning && Date() < deadline { _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } if process.isRunning { process.terminate() process.waitUntilExit() XCTFail("Timed out waiting for zsh to exit") } let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" XCTAssertEqual(process.terminationStatus, 0, error) return output.trimmingCharacters(in: .whitespacesAndNewlines) } }