cmux/cmuxTests/GhosttyConfigTests.swift
김서현 9844913ea1
fix: skip Korean from CJK font-codepoint-map auto-injection (#1700)
* fix: skip Korean from CJK font-codepoint-map auto-injection

The automatic CJK font-codepoint-map injection (PR #1017) maps Korean
ranges to Apple SD Gothic Neo, which has a different style/weight from
the primary terminal font. This overrides Ghostty's native
CTFontCreateForString fallback, which dynamically selects a
better-matching font for Hangul.

Ghostty itself (ghostty-org/ghostty) has no hardcoded CJK font names
and relies entirely on CTFontCreateForString for fallback. For Korean,
this native fallback produces visually consistent results with the
primary font.

Remove the Korean branch from cjkFontMappings() so Ghostty's native
fallback handles Hangul rendering. Japanese and Chinese mappings are
unaffected.

* test: update CJK font mapping tests for Korean removal

- testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean
  → renamed to testCJKFontMappingsReturnsNilForKoreanOnly
  → asserts nil since Korean is no longer auto-mapped

- testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges
  → renamed to testCJKFontMappingsMultiLanguageSkipsKorean
  → asserts no Apple SD Gothic Neo mapping exists
  → Japanese mappings remain unchanged

---------

Co-authored-by: dante-ad-shield <danate@ad-shield.io>
2026-03-20 00:14:01 -07:00

2667 lines
99 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))
}
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 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 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: userConfigContainsCJKCodepointMap
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
)
}
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = Menlo\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = fonts.conf\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = \(included.path)?\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let fileA = dir.appendingPathComponent("a.conf")
let fileB = dir.appendingPathComponent("b.conf")
try "config-file = \(fileB.path)\n"
.write(to: fileA, atomically: true, encoding: .utf8)
try "config-file = \(fileA.path)\n"
.write(to: fileB, atomically: true, encoding: .utf8)
// Should not hang; should return false since neither file has font-codepoint-map
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
}
}
final class 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)
}
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
) throws -> String {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)")
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
let userZdotdir = root.appendingPathComponent("zdotdir")
try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true)
try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration")
let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src")
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = [
"-i",
"-c", 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"
}
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)
}
}
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)
}
}