cmux/cmuxTests/GhosttyConfigTests.swift
2026-03-30 04:15:23 -07:00

3535 lines
133 KiB
Swift

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 testLoadThemeResolvesITerm2SolarizedLightAliasToLegacyThemeName() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ghostty-solarized-light-\(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("Solarized Light"),
atomically: true,
encoding: .utf8
)
var config = GhosttyConfig()
config.loadTheme(
"iTerm2 Solarized Light",
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
bundleResourceURL: nil
)
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
}
func testLoadThemeResolvesITerm2SolarizedDarkAliasToLegacyThemeName() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ghostty-solarized-dark-\(UUID().uuidString)")
let themesDir = root.appendingPathComponent("themes")
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try """
background = #002b36
foreground = #93a1a1
""".write(
to: themesDir.appendingPathComponent("Solarized Dark"),
atomically: true,
encoding: .utf8
)
var config = GhosttyConfig()
config.loadTheme(
"iTerm2 Solarized Dark",
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
bundleResourceURL: nil
)
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 0, green: 43, blue: 54))
}
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 testCmuxAppSupportConfigURLsUseReleaseConfigForDebugBundleWithoutCurrentConfig() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
let releaseConfigURL = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: appSupportDirectory
),
[releaseConfigURL]
)
}
}
func testCmuxAppSupportConfigURLsPreferCurrentBundleConfigWhenPresent() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
let currentConfigURL = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
filename: "config.ghostty",
contents: "font-size = 14\n"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
appSupportDirectory: appSupportDirectory
),
[currentConfigURL]
)
}
}
func testCmuxAppSupportConfigURLsSkipReleaseFallbackForNonDebugBundle() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
XCTAssertTrue(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.example.other-app",
appSupportDirectory: appSupportDirectory
).isEmpty
)
}
}
func testCmuxAppSupportConfigURLsIgnoreMissingOrEmptyFiles() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config.ghostty",
contents: ""
)
XCTAssertTrue(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: appSupportDirectory
).isEmpty
)
}
}
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))
}
func testTerminalCopyOnSelectDefaultsToDisabledWhenUnset() {
let suiteName = "cmux.tests.copy-on-select.\(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: TerminalCopyOnSelectSettings.enabledKey)
XCTAssertFalse(TerminalCopyOnSelectSettings.isEnabled(defaults: defaults))
XCTAssertEqual(
TerminalCopyOnSelectSettings.overrideConfigLine(defaults: defaults),
"copy-on-select = false"
)
}
func testTerminalCopyOnSelectUsesClipboardOverrideWhenEnabled() {
let suiteName = "cmux.tests.copy-on-select.\(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: TerminalCopyOnSelectSettings.enabledKey)
XCTAssertTrue(TerminalCopyOnSelectSettings.isEnabled(defaults: defaults))
XCTAssertEqual(
TerminalCopyOnSelectSettings.overrideConfigLine(defaults: defaults),
"copy-on-select = clipboard"
)
}
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))
)
}
private func withTemporaryAppSupportDirectory(
_ body: (URL) throws -> Void
) throws {
let fileManager = FileManager.default
let directory = fileManager.temporaryDirectory
.appendingPathComponent("cmux-app-support-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: directory) }
try body(directory)
}
private func writeAppSupportConfig(
appSupportDirectory: URL,
bundleIdentifier: String,
filename: String,
contents: String
) throws -> URL {
let fileManager = FileManager.default
let bundleDirectory = appSupportDirectory
.appendingPathComponent(bundleIdentifier, isDirectory: true)
try fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true)
let configURL = bundleDirectory.appendingPathComponent(filename, isDirectory: false)
try contents.write(to: configURL, atomically: true, encoding: .utf8)
return configURL
}
}
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 testBuffersSplitLoopbackAliasHeadersUntilFullRequestArrives() {
var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter(
aliasHost: "cmux-loopback.localtest.me"
)
let firstChunk = Data(
(
"GET /demo HTTP/1.1\r\n" +
"Host: cmux-loop"
).utf8
)
let secondChunk = Data(
(
"back.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" +
"body=1"
).utf8
)
let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false)
let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: false)
XCTAssertTrue(firstOutput.isEmpty)
let text = String(decoding: secondOutput, as: UTF8.self)
XCTAssertTrue(text.contains("Host: localhost:3000"))
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
XCTAssertTrue(text.hasSuffix("\r\n\r\nbody=1"))
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
}
func testFlushesBufferedLoopbackAliasHeadersOnEOFWhenHeadersRemainIncomplete() {
var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter(
aliasHost: "cmux-loopback.localtest.me"
)
let firstChunk = Data(
(
"GET /demo HTTP/1.1\r\n" +
"Host: cmux-loop"
).utf8
)
let secondChunk = Data(
(
"back.localtest.me:3000\r\n" +
"Origin: http://cmux-loopback.localtest.me:3000\r\n" +
"Referer: http://cmux-loopback.localtest.me:3000/app\r\n" +
"body=1"
).utf8
)
let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false)
let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: true)
let thirdOutput = streamRewriter.rewriteNextChunk(Data(), eof: true)
XCTAssertTrue(firstOutput.isEmpty)
let text = String(decoding: secondOutput, as: UTF8.self)
XCTAssertTrue(text.contains("Host: localhost:3000"))
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
XCTAssertTrue(text.hasSuffix("\r\nbody=1"))
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
XCTAssertTrue(thirdOutput.isEmpty)
}
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=/"))
}
}
final class GhosttyTerminalStartupEnvironmentTests: XCTestCase {
func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() {
let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)"
let merged = TerminalSurface.mergedStartupEnvironment(
base: [
"PATH": "/usr/bin",
"CMUX_SURFACE_ID": "managed-surface"
],
protectedKeys: ["PATH", "CMUX_SURFACE_ID"],
additionalEnvironment: [
SessionScrollbackReplayStore.environmentKey: replayPath
],
initialEnvironmentOverrides: [
"CMUX_INITIAL_ENV_TOKEN": "token-123"
]
)
XCTAssertEqual(merged[SessionScrollbackReplayStore.environmentKey], replayPath)
XCTAssertEqual(merged["CMUX_INITIAL_ENV_TOKEN"], "token-123")
}
func testMergedStartupEnvironmentProtectsManagedKeysOnly() {
let merged = TerminalSurface.mergedStartupEnvironment(
base: [
"PATH": "/usr/bin",
"CMUX_SURFACE_ID": "managed-surface"
],
protectedKeys: ["PATH", "CMUX_SURFACE_ID"],
additionalEnvironment: [
"CMUX_SURFACE_ID": "user-surface",
"CUSTOM_FLAG": "1"
],
initialEnvironmentOverrides: [
"PATH": "/tmp/bin",
"CMUX_SURFACE_ID": "override-surface"
]
)
XCTAssertEqual(merged["PATH"], "/usr/bin")
XCTAssertEqual(merged["CMUX_SURFACE_ID"], "managed-surface")
XCTAssertEqual(merged["CUSTOM_FLAG"], "1")
}
}
@MainActor
final class BrowserPanelPopupContextTests: XCTestCase {
func testFloatingPopupInheritsOpenerBrowserContext() throws {
let panel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
let popupWebView = try XCTUnwrap(
panel.createFloatingPopup(
configuration: WKWebViewConfiguration(),
windowFeatures: WKWindowFeatures()
)
)
defer { popupWebView.window?.close() }
XCTAssertTrue(
popupWebView.configuration.processPool === panel.webView.configuration.processPool
)
XCTAssertTrue(
popupWebView.configuration.websiteDataStore === panel.webView.configuration.websiteDataStore
)
}
func testFloatingPopupInheritsRemoteWorkspaceWebsiteDataStore() throws {
let remoteWorkspaceId = UUID()
let panel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
let popupWebView = try XCTUnwrap(
panel.createFloatingPopup(
configuration: WKWebViewConfiguration(),
windowFeatures: WKWindowFeatures()
)
)
defer { popupWebView.window?.close() }
XCTAssertTrue(
popupWebView.configuration.websiteDataStore === panel.webView.configuration.websiteDataStore
)
XCTAssertFalse(popupWebView.configuration.websiteDataStore === WKWebsiteDataStore.default())
}
}
@MainActor
final class BrowserPanelRemoteStoreTests: XCTestCase {
func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
let remoteWorkspaceId = UUID()
let firstRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
let secondRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertTrue(
firstRemotePanel.webView.configuration.websiteDataStore ===
secondRemotePanel.webView.configuration.websiteDataStore
)
}
func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() {
let remoteWorkspaceId = UUID()
let url = URL(string: "http://localhost:3000/demo")!
let panel = BrowserPanel(
workspaceId: remoteWorkspaceId,
initialURL: url,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertNil(panel.webView.url)
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me")
}
func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() {
let remoteWorkspaceId = UUID()
let url = URL(string: "https://localhost:3443/demo")!
let panel = BrowserPanel(
workspaceId: remoteWorkspaceId,
initialURL: url,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertNil(panel.webView.url)
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertEqual(panel.webView.url?.host, "localhost")
}
func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws {
let source = Workspace()
let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first)
let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
let localStore = sourceBrowser.webView.configuration.websiteDataStore
XCTAssertTrue(localStore === WKWebsiteDataStore.default())
let destination = Workspace()
destination.configureRemoteConnection(
WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: 22,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64001,
relayID: "relay-store-dest",
relayToken: String(repeating: "a", count: 64),
localSocketPath: "/tmp/cmux-store-dest.sock",
terminalStartupCommand: "ssh cmux-macmini"
),
autoConnect: false
)
let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first)
let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false))
let destinationStore = destinationBrowser.webView.configuration.websiteDataStore
XCTAssertFalse(destinationStore === WKWebsiteDataStore.default())
let detached = try XCTUnwrap(source.detachSurface(panelId: sourceBrowser.id))
let attachedPanelId = try XCTUnwrap(
destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false)
)
let movedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel)
XCTAssertTrue(movedBrowser.webView.configuration.websiteDataStore === destinationStore)
XCTAssertFalse(movedBrowser.webView.configuration.websiteDataStore === localStore)
}
func testBrowserMoveOutOfRemoteWorkspaceRestoresDefaultWebsiteDataStore() throws {
let source = Workspace()
source.configureRemoteConnection(
WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: 22,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64002,
relayID: "relay-store-source",
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-store-source.sock",
terminalStartupCommand: "ssh cmux-macmini"
),
autoConnect: false
)
let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first)
let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore
XCTAssertFalse(remoteStore === WKWebsiteDataStore.default())
let destination = Workspace()
let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first)
let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id))
let attachedPanelId = try XCTUnwrap(
destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false)
)
let attachedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel)
XCTAssertTrue(attachedBrowser.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertTrue(remainingRemoteBrowser.webView.configuration.websiteDataStore === remoteStore)
XCTAssertFalse(remainingRemoteBrowser.webView.configuration.websiteDataStore === attachedBrowser.webView.configuration.websiteDataStore)
}
func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws {
let workspace = Workspace()
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId)
let configuration = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64000,
relayID: "relay-test",
relayToken: String(repeating: "a", count: 64),
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(configuration, autoConnect: false)
_ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false)
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort)
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
_ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false))
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
}
}
final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase {
func testProxyBrokerTransportKeyIgnoresControlPath() {
let first = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: 22,
identityFile: "~/.ssh/id_ed25519",
sshOptions: [
"Compression=yes",
"ControlMaster=auto",
"ControlPath=/tmp/cmux-ssh-501-64000-%C",
],
localProxyPort: 9000,
relayPort: 64000,
relayID: "relay-a",
relayToken: "token-a",
localSocketPath: "/tmp/cmux-a.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
let second = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: 22,
identityFile: "~/.ssh/id_ed25519",
sshOptions: [
"Compression=yes",
"ControlMaster=auto",
"ControlPath=/tmp/cmux-ssh-501-64001-%C",
],
localProxyPort: 9000,
relayPort: 64001,
relayID: "relay-b",
relayToken: "token-b",
localSocketPath: "/tmp/cmux-b.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey)
}
}
final class TitlebarDoubleClickPreferenceTests: XCTestCase {
func testResolvesZoomForFillPreference() {
XCTAssertEqual(
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
"AppleActionOnDoubleClick": "Fill",
]),
.zoom
)
}
func testResolvesMiniaturizeForExplicitMinimizePreference() {
XCTAssertEqual(
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
"AppleActionOnDoubleClick": "Minimize",
]),
.miniaturize
)
}
func testResolvesNoneForNoActionPreference() {
XCTAssertEqual(
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
"AppleActionOnDoubleClick": "No Action",
]),
.none
)
}
func testFallsBackToLegacyMiniaturizePreference() {
XCTAssertEqual(
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [
"AppleMiniaturizeOnDoubleClick": true,
]),
.miniaturize
)
}
func testDefaultsToZoomWhenPreferenceIsMissing() {
XCTAssertEqual(
resolvedStandardTitlebarDoubleClickAction(globalDefaults: [:]),
.zoom
)
}
}
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)"),
profileID: nil,
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,
probeStableDefaultPathEntry: { _ in .missing }
)
XCTAssertEqual(path, SocketControlSettings.stableDefaultSocketPath)
}
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,
probeStableDefaultPathEntry: { _ in .missing }
)
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,
probeStableDefaultPathEntry: { _ in .missing }
)
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
}
func testDefaultSocketPathByChannel() {
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
SocketControlSettings.stableDefaultSocketPath
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-nightly.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.debug.tag",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-debug.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.staging.tag",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-staging.sock"
)
}
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathOwnedByDifferentUser() {
let path = SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
currentUserID: 501,
probeStableDefaultPathEntry: { _ in .socket(ownerUserID: 0) }
)
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
}
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathIsBlockedByNonSocketEntry() {
let path = SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
currentUserID: 501,
probeStableDefaultPathEntry: { _ in .other(ownerUserID: 501) }
)
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
}
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 UITestLaunchManifestTests: XCTestCase {
func testManifestPathReadsArgumentValue() {
XCTAssertEqual(
UITestLaunchManifest.manifestPath(
from: ["cmux", "-cmuxUITestLaunchManifest", "/tmp/cmux-ui-test-launch.json"]
),
"/tmp/cmux-ui-test-launch.json"
)
}
func testManifestPathReturnsNilWithoutValue() {
XCTAssertNil(
UITestLaunchManifest.manifestPath(
from: ["cmux", "-cmuxUITestLaunchManifest"]
)
)
}
func testApplyIfPresentDecodesEnvironmentPayload() {
let payload = """
{"environment":{"CMUX_TAG":"ui-tests-display","CMUX_SOCKET_PATH":"/tmp/cmux-ui-tests.sock"}}
""".data(using: .utf8)!
var applied: [String: String] = [:]
UITestLaunchManifest.applyIfPresent(
arguments: ["cmux", UITestLaunchManifest.argumentName, "/tmp/cmux-ui-test-launch.json"],
loadData: { _ in payload },
applyEnvironment: { key, value in
applied[key] = value
}
)
XCTAssertEqual(applied["CMUX_TAG"], "ui-tests-display")
XCTAssertEqual(applied["CMUX_SOCKET_PATH"], "/tmp/cmux-ui-tests.sock")
}
}
final class PostHogAnalyticsPropertiesTests: XCTestCase {
func testDailyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.dailyActiveProperties(
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 testCJKFontMappingsReturnsNilForKoreanOnly() {
// Korean is not auto-mapped Ghostty's native CTFontCreateForString
// fallback selects a better-matching font for Hangul.
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"]))
}
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 testCJKFontMappingsMultiLanguageSkipsKorean() {
// When both ja and ko are preferred, only Japanese mappings are generated.
// Korean is left to Ghostty's native CTFontCreateForString fallback.
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
XCTAssertFalse(mappings.contains { $0.1 == "Apple SD Gothic Neo" }, "No Korean font mapping")
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
}
// MARK: autoInjectedCJKFontMappings
func testAutoInjectedCJKFontMappingsSkipsRangesCoveredByConfiguredPrimaryFont() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Sarasa Mono K\n") { path in
XCTAssertNil(
GhosttyApp.autoInjectedCJKFontMappings(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path],
rangeCoverageProbe: { fontFamily, range in
XCTAssertEqual(fontFamily, "Sarasa Mono K")
return coveredRanges.contains(range)
}
)
)
}
}
func testAutoInjectedCJKFontMappingsKeepsOnlyUncoveredRanges() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Example CJK Mono\n") { path in
let mappings = GhosttyApp.autoInjectedCJKFontMappings(
preferredLanguages: ["ja-JP"],
configPaths: [path],
rangeCoverageProbe: { _, range in
coveredRanges.contains(range)
}
)!
XCTAssertEqual(Set(mappings.map(\.0)), Set(["U+3040-U+309F", "U+30A0-U+30FF"]))
XCTAssertEqual(Set(mappings.map(\.1)), Set(["Hiragino Sans"]))
}
}
// 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]))
}
func testUserConfigContainsCJKCodepointMapRespectsReset() throws {
try withTempConfig("""
font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans
font-codepoint-map =
""") { path in
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
)
}
}
// MARK: userConfigHasExplicitFontFamilyFallbackChain
func testUserConfigHasExplicitFontFamilyFallbackChainDetectsMultipleEntries() throws {
try withTempConfig("""
font-family = JetBrains Mono
font-family = LXGW WenKai Mono TC
""") { path in
XCTAssertTrue(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(configPaths: [path])
)
}
}
func testUserConfigHasExplicitFontFamilyFallbackChainFollowsConfigFileIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-font-family-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-family = LXGW WenKai Mono TC\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = JetBrains Mono\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(configPaths: [main.path])
)
}
func testUserConfigHasExplicitFontFamilyFallbackChainRespectsFontFamilyReset() throws {
try withTempConfig("""
font-family = JetBrains Mono
font-family =
font-family = LXGW WenKai Mono TC
""") { path in
XCTAssertFalse(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(configPaths: [path])
)
}
}
func testUserConfigHasExplicitFontFamilyFallbackChainIgnoresDuplicateFamilies() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-font-family-duplicate-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let legacy = dir.appendingPathComponent("config")
try "font-family = JetBrains Mono\n"
.write(to: legacy, atomically: true, encoding: .utf8)
let preferred = dir.appendingPathComponent("config.ghostty")
try "font-family = JetBrains Mono\n"
.write(to: preferred, atomically: true, encoding: .utf8)
XCTAssertFalse(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(
configPaths: [legacy.path, preferred.path]
)
)
}
func testUserConfigHasExplicitFontFamilyFallbackChainMatchesGhosttyIncludeLoadOrder() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-font-family-order-\(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-family = LXGW WenKai Mono TC\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = JetBrains Mono\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
let reset = dir.appendingPathComponent("config.ghostty")
try "font-family =\n"
.write(to: reset, atomically: true, encoding: .utf8)
XCTAssertFalse(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(
configPaths: [main.path, reset.path]
)
)
}
func testUserConfigHasExplicitFontFamilyFallbackChainRespectsConfigFileReset() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-font-family-config-file-reset-\(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-family = LXGW WenKai Mono TC\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = JetBrains Mono\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
let reset = dir.appendingPathComponent("config.ghostty")
try "config-file =\n"
.write(to: reset, atomically: true, encoding: .utf8)
XCTAssertFalse(
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(
configPaths: [main.path, reset.path]
)
)
}
// MARK: shouldInjectCJKFontFallback
func testShouldInjectCJKFontFallbackSkipsExplicitMultiFontFallbackChain() throws {
try withTempConfig("""
font-family = JetBrains Mono
font-family = LXGW WenKai Mono TC
""") { path in
XCTAssertFalse(
GhosttyApp.shouldInjectCJKFontFallback(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path]
)
)
}
}
func testShouldInjectCJKFontFallbackAllowsSingleFontWithoutExplicitOverrides() throws {
try withTempConfig("font-family = JetBrains Mono\n") { path in
XCTAssertTrue(
GhosttyApp.shouldInjectCJKFontFallback(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path]
)
)
}
}
func testShouldInjectCJKFontFallbackSkipsConfiguredFontThatAlreadyCoversMappedRanges() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Sarasa Mono K\n") { path in
XCTAssertFalse(
GhosttyApp.shouldInjectCJKFontFallback(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path],
rangeCoverageProbe: { fontFamily, range in
XCTAssertEqual(fontFamily, "Sarasa Mono K")
return coveredRanges.contains(range)
}
)
)
}
}
func testUserConfigDefinesShiftEnterBindingDetectsDirectBinding() throws {
try withTempConfig("keybind = shift+enter=text:\\x0a\n") { path in
XCTAssertTrue(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
)
}
}
func testUserConfigDefinesShiftEnterBindingDetectsUnbindInIncludedFile() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-shift-enter-unbind-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("bindings.conf")
try "keybind = shift+enter=unbind\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.userConfigDefinesShiftEnterBinding(configPaths: [main.path])
)
}
func testUserConfigDefinesShiftEnterBindingHonorsLaterClearInIncludedFile() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-shift-enter-clear-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("bindings.conf")
try "keybind = clear\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try """
keybind = shift+enter=text:\\x0a
config-file = \(included.path)
"""
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertFalse(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path])
)
}
func testUserConfigDefinesShiftEnterBindingIgnoresOtherModifierCombinations() throws {
try withTempConfig("keybind = cmd+shift+enter=text:\\x0a\n") { path in
XCTAssertFalse(
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
)
}
}
func testShouldRemapShiftEnterForTmuxOnlyWhenScopedToTmuxWithoutOverrides() {
XCTAssertTrue(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: false,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: false,
userConfigDefinesShiftEnterBinding: false,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: true,
hasMarkedText: false
)
)
XCTAssertFalse(
GhosttyApp.shouldRemapShiftEnterForTmux(
keyCode: 36,
modifierFlags: [.shift, .command],
isInsideTmux: true,
userConfigDefinesShiftEnterBinding: false,
hasMarkedText: false
)
)
}
func testForegroundTmuxProcessOnTTYIsDetected() {
let processes = [
TerminalSSHSessionDetector.ProcessSnapshot(
pid: 47486,
pgid: 47486,
tpgid: 48365,
tty: "ttys089",
executableName: "login"
),
TerminalSSHSessionDetector.ProcessSnapshot(
pid: 47487,
pgid: 47487,
tpgid: 48365,
tty: "ttys089",
executableName: "zsh"
),
TerminalSSHSessionDetector.ProcessSnapshot(
pid: 48365,
pgid: 48365,
tpgid: 48365,
tty: "ttys089",
executableName: "tmux"
),
]
XCTAssertTrue(
TerminalSSHSessionDetector.isInsideTmuxForTesting(
ttyName: "ttys089",
processes: processes
)
)
XCTAssertFalse(
TerminalSSHSessionDetector.isInsideTmuxForTesting(
ttyName: "ttys090",
processes: processes
)
)
XCTAssertFalse(
TerminalSSHSessionDetector.isInsideTmuxForTesting(
ttyName: "ttys089",
processes: processes.filter { $0.executableName != "tmux" }
)
)
}
func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws {
let appSupport = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: appSupport) }
let taggedDir = appSupport.appendingPathComponent("com.example.cmux-dev", isDirectory: true)
try FileManager.default.createDirectory(at: taggedDir, withIntermediateDirectories: true)
let taggedConfig = taggedDir.appendingPathComponent("config", isDirectory: false)
try "font-family = JetBrains Mono\n"
.write(to: taggedConfig, atomically: true, encoding: .utf8)
let releaseDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true)
try FileManager.default.createDirectory(at: releaseDir, withIntermediateDirectories: true)
let releaseConfig = releaseDir.appendingPathComponent("config", isDirectory: false)
try "font-family = LXGW WenKai Mono TC\n"
.write(to: releaseConfig, atomically: true, encoding: .utf8)
let paths = GhosttyApp.loadedCJKScanPaths(
currentBundleIdentifier: "com.example.cmux-dev",
appSupportDirectory: appSupport
)
XCTAssertTrue(paths.contains(taggedConfig.path))
XCTAssertFalse(paths.contains(releaseConfig.path))
XCTAssertTrue(
GhosttyApp.shouldInjectCJKFontFallback(
preferredLanguages: ["zh-Hans-CN"],
configPaths: paths
)
)
}
}
final class SidebarBackgroundConfigTests: XCTestCase {
func testParseSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.parse("sidebar-background = #336699")
XCTAssertEqual(config.rawSidebarBackground, "#336699")
}
func testParseSidebarBackgroundDualMode() {
var config = GhosttyConfig()
config.parse("sidebar-background = light:#fbf3db,dark:#103c48")
XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48")
}
func testParseSidebarTintOpacity() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 0.4")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedAboveOne() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 1.5")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedBelowZero() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = -0.3")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001)
}
func testResolveSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testResolveSidebarBackgroundDualModeSetsLightAndDark() {
var config = GhosttyConfig()
config.rawSidebarBackground = "light:#fbf3db,dark:#103c48"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackgroundLight)
XCTAssertNotNil(config.sidebarBackgroundDark)
XCTAssertNotNil(config.sidebarBackground)
}
func testResolveSidebarBackgroundNilWhenNoRaw() {
var config = GhosttyConfig()
config.resolveSidebarBackground(preferredColorScheme: .dark)
XCTAssertNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testApplyToUserDefaultsSkipsWritesWhenNoConfig() {
let defaults = UserDefaults.standard
let testKey = "sidebarTintHex"
let original = defaults.string(forKey: testKey)
defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) }
defaults.set("#AAAAAA", forKey: testKey)
var config = GhosttyConfig()
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA",
"Should not overwrite UserDefaults when rawSidebarBackground is nil")
}
func testApplyToUserDefaultsWritesHexWhenConfigSet() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"))
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"))
}
func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set("#AAAAAA", forKey: "sidebarTintHexLight")
defaults.set("#BBBBBB", forKey: "sidebarTintHexDark")
var config = GhosttyConfig()
config.rawSidebarBackground = "#222222"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"),
"Stale light key should be cleared")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"),
"Stale dark key should be cleared")
}
func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set(0.18, forKey: "sidebarTintOpacity")
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001,
"Should not overwrite opacity when config doesn't set sidebar-tint-opacity")
}
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
if let value = value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
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)
}
func testGhosttySemanticPatchRetriesAfterDeferredInitCreatesLiveHooks() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: true,
cmuxLoadShellIntegration: true,
command: """
_cmux_patch_ghostty_semantic_redraw
(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1
_cmux_patch_ghostty_semantic_redraw
print -r -- "PRECMD_BODY=${functions[_ghostty_precmd]}"
print -r -- "PREEXEC_BODY=${functions[_ghostty_preexec]}"
"""
)
XCTAssertTrue(output.contains("PRECMD_BODY="), output)
XCTAssertTrue(output.contains("PREEXEC_BODY="), output)
XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output)
}
func testShellIntegrationWinchGuardDoesNotPrintSpacerLineOnResize() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
print -r -- BEFORE
TRAPWINCH
print -r -- AFTER
"""
)
XCTAssertEqual(output, "BEFORE\nAFTER", output)
}
func testShellIntegrationPublishesOnlyWorkspaceScopedCmuxEnvironmentToTmuxServerAutomatically() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-tmux-publish-\(UUID().uuidString)")
let binDir = root.appendingPathComponent("bin", isDirectory: true)
let logPath = root.appendingPathComponent("tmux.log", isDirectory: false)
try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
try writeExecutableScript(
at: binDir.appendingPathComponent("tmux", isDirectory: false),
contents: """
#!/bin/sh
if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then
exit 0
fi
printf '%s\\n' "$*" >> "\(logPath.path)"
exit 0
"""
)
_ = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: "_cmux_preexec tmux; print -r -- READY",
extraEnvironment: [
"PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin",
"CMUX_SOCKET_PATH": "/tmp/cmux-current.sock",
"CMUX_TAG": "feat-tmux-notification-attention-state",
"CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222",
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222",
]
)
let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? ""
XCTAssertTrue(log.contains("set-environment -g CMUX_TAG feat-tmux-notification-attention-state"), log)
XCTAssertTrue(log.contains("set-environment -g CMUX_SOCKET_PATH /tmp/cmux-current.sock"), log)
XCTAssertTrue(log.contains("set-environment -g CMUX_WORKSPACE_ID 11111111-1111-1111-1111-111111111111"), log)
XCTAssertFalse(log.contains("set-environment -g CMUX_SURFACE_ID"), log)
XCTAssertFalse(log.contains("set-environment -g CMUX_PANEL_ID"), log)
}
func testShellIntegrationClearsStaleSurfaceScopedTmuxEnvironmentAutomatically() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-tmux-clear-\(UUID().uuidString)")
let binDir = root.appendingPathComponent("bin", isDirectory: true)
let logPath = root.appendingPathComponent("tmux.log", isDirectory: false)
try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
try writeExecutableScript(
at: binDir.appendingPathComponent("tmux", isDirectory: false),
contents: """
#!/bin/sh
if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then
printf '%s\\n' 'CMUX_SURFACE_ID=99999999-9999-9999-9999-999999999999'
printf '%s\\n' 'CMUX_PANEL_ID=99999999-9999-9999-9999-999999999999'
exit 0
fi
printf '%s\\n' "$*" >> "\(logPath.path)"
exit 0
"""
)
_ = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: "_cmux_preexec tmux; print -r -- READY",
extraEnvironment: [
"PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin",
"CMUX_SOCKET_PATH": "/tmp/cmux-current.sock",
"CMUX_TAG": "feat-tmux-notification-attention-state",
"CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222",
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222",
]
)
let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? ""
XCTAssertTrue(log.contains("set-environment -gu CMUX_SURFACE_ID"), log)
XCTAssertTrue(log.contains("set-environment -gu CMUX_PANEL_ID"), log)
}
func testShellIntegrationRefreshesWorkspaceScopedCmuxEnvironmentFromTmuxWithoutOverwritingSurfaceScope() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-tmux-refresh-\(UUID().uuidString)")
let binDir = root.appendingPathComponent("bin", isDirectory: true)
try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
try writeExecutableScript(
at: binDir.appendingPathComponent("tmux", isDirectory: false),
contents: """
#!/bin/sh
if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then
printf '%s\\n' 'CMUX_SOCKET_PATH=/tmp/cmux-current.sock'
printf '%s\\n' 'CMUX_TAG=feat-tmux-notification-attention-state'
printf '%s\\n' 'CMUX_WORKSPACE_ID=11111111-1111-1111-1111-111111111111'
printf '%s\\n' 'CMUX_SURFACE_ID=99999999-9999-9999-9999-999999999999'
printf '%s\\n' 'CMUX_TAB_ID=11111111-1111-1111-1111-111111111111'
printf '%s\\n' 'CMUX_PANEL_ID=99999999-9999-9999-9999-999999999999'
exit 0
fi
exit 0
"""
)
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: "_cmux_precmd; print -r -- \"$CMUX_TAG|$CMUX_SOCKET_PATH|$CMUX_WORKSPACE_ID|$CMUX_SURFACE_ID|$CMUX_PANEL_ID\"",
extraEnvironment: [
"PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin",
"TMUX": "/tmp/tmux-stale,123,0",
"CMUX_SOCKET_PATH": "/tmp/cmux-stale.sock",
"CMUX_TAG": "feat-tmux-integration-experiments",
"CMUX_WORKSPACE_ID": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA",
"CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222",
"CMUX_TAB_ID": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA",
"CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222",
]
)
XCTAssertEqual(
output,
"feat-tmux-notification-attention-state|/tmp/cmux-current.sock|11111111-1111-1111-1111-111111111111|22222222-2222-2222-2222-222222222222|22222222-2222-2222-2222-222222222222"
)
}
func testShellIntegrationReportsTTYFromTmuxWithoutUsingPanelScope() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
_CMUX_TTY_NAME=ttys999
print -r -- "$(_cmux_report_tty_payload)"
""",
extraEnvironment: [
"TMUX": "/tmp/tmux-current,123,0",
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999",
]
)
XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111")
}
func testShellIntegrationReportsTmuxStatePayload() throws {
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
_CMUX_TTY_NAME=ttys999
print -r -- "$(_cmux_report_tmux_state_payload)"
""",
extraEnvironment: [
"TMUX": "/tmp/tmux-current,123,0",
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999",
]
)
XCTAssertEqual(
output,
"report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999"
)
}
func testShellIntegrationResendsTmuxStateWhenSocketTargetChanges() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-tmux-state-resend-\(UUID().uuidString)")
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
let socketA = root.appendingPathComponent("cmux-a.sock").path
let socketB = root.appendingPathComponent("cmux-b.sock").path
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \
os.path.exists(path) and os.unlink(path); \
s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" &
server_a=$!
sleep 0.1
functions[_cmux_send_bg]='print -r -- "$1"'
_CMUX_TTY_NAME=ttys999
_CMUX_TMUX_STATE_SIGNATURE_LAST=""
_cmux_report_tmux_state
kill $server_a >/dev/null 2>&1
wait $server_a >/dev/null 2>&1
export CMUX_SOCKET_PATH="\(socketB)"
python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \
os.path.exists(path) and os.unlink(path); \
s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" &
server_b=$!
sleep 0.1
_cmux_report_tmux_state
kill $server_b >/dev/null 2>&1
wait $server_b >/dev/null 2>&1
""",
extraEnvironment: [
"TMUX": "/tmp/tmux-current,123,0",
"CMUX_SOCKET_PATH": socketA,
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
"CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999",
]
)
XCTAssertEqual(
output,
"""
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999
"""
)
}
func testShellIntegrationPrecmdReportsTmuxStateWithoutPanelScope() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-precmd-tmux-state-\(UUID().uuidString)")
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
let socketPath = root.appendingPathComponent("cmux.sock").path
let output = try runInteractiveZsh(
cmuxLoadGhosttyIntegration: false,
cmuxLoadShellIntegration: true,
command: """
python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \
os.path.exists(path) and os.unlink(path); \
s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" &
server_pid=$!
sleep 0.1
functions[_cmux_send_bg]='print -r -- "$1"'
unset CMUX_PANEL_ID
_CMUX_TTY_NAME=ttys999
_CMUX_TTY_REPORTED=0
_CMUX_TMUX_STATE_SIGNATURE_LAST=""
_cmux_precmd
kill $server_pid >/dev/null 2>&1
wait $server_pid >/dev/null 2>&1
""",
extraEnvironment: [
"TMUX": "/tmp/tmux-current,123,0",
"CMUX_SOCKET_PATH": socketPath,
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
]
)
XCTAssertEqual(
output,
"""
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999
report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111
"""
)
}
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
try runInteractiveZsh(
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
cmuxLoadShellIntegration: false,
command: "(( $+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}\""
)
}
private func runInteractiveZsh(
cmuxLoadGhosttyIntegration: Bool,
cmuxLoadShellIntegration: Bool,
command: String,
extraEnvironment: [String: String] = [:]
) 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)
let userZshEnvContents: String = {
if let path = extraEnvironment["PATH"] {
let escaped = path.replacingOccurrences(of: "\"", with: "\\\"")
return "export PATH=\"\(escaped)\"\n"
}
return "\n"
}()
try userZshEnvContents.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", command
]
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"
}
if cmuxLoadShellIntegration {
process.environment?["CMUX_SHELL_INTEGRATION"] = "1"
process.environment?["CMUX_SHELL_INTEGRATION_DIR"] = cmuxZdotdir.path
process.environment?["CMUX_SOCKET_PATH"] = root.appendingPathComponent("cmux-test.sock").path
process.environment?["CMUX_TAB_ID"] = "tab-test"
process.environment?["CMUX_PANEL_ID"] = "panel-test"
}
for (key, value) in extraEnvironment {
process.environment?[key] = value
}
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)
}
private func writeExecutableScript(at url: URL, contents: String) throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func bindUnixSocket(at path: String) throws -> Int32 {
unlink(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(
domain: NSPOSIXErrorDomain,
code: Int(errno),
userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"]
)
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(pathBuf, ptr, maxPathLength - 1)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult == 0 else {
let code = Int(errno)
Darwin.close(fd)
throw NSError(
domain: NSPOSIXErrorDomain,
code: code,
userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"]
)
}
guard Darwin.listen(fd, 1) == 0 else {
let code = Int(errno)
Darwin.close(fd)
throw NSError(
domain: NSPOSIXErrorDomain,
code: code,
userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"]
)
}
return fd
}
}
final class BrowserInstallDetectorTests: XCTestCase {
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
contents: Data()
)
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { bundleIdentifier in
if bundleIdentifier == "com.google.Chrome" {
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
}
return nil
},
applicationSearchDirectories: []
)
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
XCTFail("Expected Chrome to be detected")
return
}
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
XCTFail("Expected Firefox to be detected from profile data")
return
}
XCTAssertNotNil(chrome.appURL)
XCTAssertEqual(firefox.profileURLs.count, 1)
XCTAssertNil(firefox.appURL)
}
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.isEmpty)
}
func testUngoogledChromiumRequiresAppSignal() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
}
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
try createFile(
at: heliumRoot.appendingPathComponent("Default/History"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Local State"),
contents: Data(
"""
{
"profile": {
"info_cache": {
"Default": {
"name": "Personal"
},
"Profile 1": {
"name": "Work"
}
}
}
}
""".utf8
)
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
XCTFail("Expected Helium to be detected")
return
}
XCTAssertEqual(helium.family, .chromium)
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
XCTAssertEqual(
helium.profiles.map(\.rootURL.lastPathComponent),
["Default", "Profile 1"]
)
}
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home.appendingPathComponent("Library/Safari/History.db"),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Safari/Profiles/Work/History.db"
),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
XCTFail("Expected Safari to be detected")
return
}
XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"]))
XCTAssertEqual(
safari.profiles
.map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) }
.sorted(),
[
home.appendingPathComponent("Library/Safari", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
isDirectory: true
).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
].sorted()
)
}
private func makeTemporaryHome() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
}
private func createFile(at url: URL, contents: Data) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
guard FileManager.default.createFile(atPath: url.path, contents: contents) else {
throw CocoaError(
.fileWriteUnknown,
userInfo: [NSFilePathErrorKey: url.path]
)
}
}
}
final class BrowserImportScopeTests: XCTestCase {
func testFromSelectionCookiesOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesOnly)
}
func testFromSelectionHistoryOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .historyOnly)
}
func testFromSelectionCookiesAndHistory() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesAndHistory)
}
func testFromSelectionEverything() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: true
)
XCTAssertEqual(scope, .everything)
}
func testFromSelectionRejectsEmptySelection() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertNil(scope)
}
}