- Split Unicode ranges by language to avoid mapping Hangul to Hiragino Sans or Kana to Apple SD Gothic Neo. Shared CJK ranges (ideographs, symbols, fullwidth forms) use the first CJK language's font, while script-specific ranges (Kana, Hangul) only map to their own font. - Use UUID-based temp file path to prevent race conditions on concurrent launches. - Move fallback injection after ghostty_config_load_recursive_files so that config-file includes are already loaded when checking for existing font-codepoint-map entries. - Follow config-file directives when scanning for existing font-codepoint-map entries. - Extract test helper withTempConfig to reduce duplication. - Add tests for multi-language mappings and config-file includes. - Replace placeholder issue URL with actual PR link.
1455 lines
52 KiB
Swift
1455 lines
52 KiB
Swift
import XCTest
|
|
import AppKit
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
final class SidebarPathFormatterTests: XCTestCase {
|
|
func testShortenedPathReplacesExactHomeDirectory() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/Users/example",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"~"
|
|
)
|
|
}
|
|
|
|
func testShortenedPathReplacesHomeDirectoryPrefix() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/Users/example/projects/cmux",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"~/projects/cmux"
|
|
)
|
|
}
|
|
|
|
func testShortenedPathLeavesExternalPathUnchanged() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/tmp/cmux",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"/tmp/cmux"
|
|
)
|
|
}
|
|
}
|
|
|
|
final class GhosttyConfigTests: XCTestCase {
|
|
private struct RGB: Equatable {
|
|
let red: Int
|
|
let green: Int
|
|
let blue: Int
|
|
}
|
|
|
|
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
|
|
let resolved = GhosttyConfig.resolveThemeName(
|
|
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
|
preferredColorScheme: .light
|
|
)
|
|
|
|
XCTAssertEqual(resolved, "Builtin Solarized Light")
|
|
}
|
|
|
|
func testResolveThemeNamePrefersDarkEntryForPairedTheme() {
|
|
let resolved = GhosttyConfig.resolveThemeName(
|
|
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
|
preferredColorScheme: .dark
|
|
)
|
|
|
|
XCTAssertEqual(resolved, "Builtin Solarized Dark")
|
|
}
|
|
|
|
func testThemeNameCandidatesIncludeBuiltinAliasForms() {
|
|
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Light")
|
|
XCTAssertEqual(candidates.first, "Builtin Solarized Light")
|
|
XCTAssertTrue(candidates.contains("Solarized Light"))
|
|
XCTAssertTrue(candidates.contains("iTerm2 Solarized Light"))
|
|
}
|
|
|
|
func testThemeNameCandidatesMapSolarizedDarkToITerm2Alias() {
|
|
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Dark")
|
|
XCTAssertTrue(candidates.contains("Solarized Dark"))
|
|
XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark"))
|
|
}
|
|
|
|
func testThemeSearchPathsIncludeXDGDataDirsThemes() {
|
|
let pathA = "/tmp/cmux-theme-a"
|
|
let pathB = "/tmp/cmux-theme-b"
|
|
let paths = GhosttyConfig.themeSearchPaths(
|
|
forThemeName: "Solarized Light",
|
|
environment: ["XDG_DATA_DIRS": "\(pathA):\(pathB)"],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertTrue(paths.contains("\(pathA)/ghostty/themes/Solarized Light"))
|
|
XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light"))
|
|
}
|
|
|
|
func testLoadThemeResolvesPairedThemeValueByColorScheme() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
try """
|
|
background = #fdf6e3
|
|
foreground = #657b83
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Light Theme"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
try """
|
|
background = #002b36
|
|
foreground = #93a1a1
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Dark Theme"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
var lightConfig = GhosttyConfig()
|
|
lightConfig.loadTheme(
|
|
"light:Light Theme,dark:Dark Theme",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil,
|
|
preferredColorScheme: .light
|
|
)
|
|
XCTAssertEqual(rgb255(lightConfig.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
|
|
|
var darkConfig = GhosttyConfig()
|
|
darkConfig.loadTheme(
|
|
"light:Light Theme,dark:Dark Theme",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil,
|
|
preferredColorScheme: .dark
|
|
)
|
|
XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54))
|
|
}
|
|
|
|
func testParseBackgroundOpacityReadsConfigValue() {
|
|
var config = GhosttyConfig()
|
|
config.parse("background-opacity = 0.42")
|
|
XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001)
|
|
}
|
|
|
|
func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let themePath = themesDir.appendingPathComponent("Solarized Light")
|
|
let themeContents = """
|
|
background = #fdf6e3
|
|
foreground = #657b83
|
|
"""
|
|
try themeContents.write(to: themePath, atomically: true, encoding: .utf8)
|
|
|
|
var config = GhosttyConfig()
|
|
config.loadTheme(
|
|
"Builtin Solarized Light",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
|
}
|
|
|
|
func testLoadCachesPerColorScheme() {
|
|
GhosttyConfig.invalidateLoadCache()
|
|
defer { GhosttyConfig.invalidateLoadCache() }
|
|
|
|
var loadCount = 0
|
|
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in
|
|
loadCount += 1
|
|
var config = GhosttyConfig()
|
|
config.fontFamily = "\(scheme)-\(loadCount)"
|
|
return config
|
|
}
|
|
|
|
let lightFirst = GhosttyConfig.load(
|
|
preferredColorScheme: .light,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
let lightSecond = GhosttyConfig.load(
|
|
preferredColorScheme: .light,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
let darkFirst = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
|
|
XCTAssertEqual(loadCount, 2)
|
|
XCTAssertEqual(lightFirst.fontFamily, "light-1")
|
|
XCTAssertEqual(lightSecond.fontFamily, "light-1")
|
|
XCTAssertEqual(darkFirst.fontFamily, "dark-2")
|
|
}
|
|
|
|
func testLoadCacheInvalidationForcesReload() {
|
|
GhosttyConfig.invalidateLoadCache()
|
|
defer { GhosttyConfig.invalidateLoadCache() }
|
|
|
|
var loadCount = 0
|
|
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in
|
|
loadCount += 1
|
|
var config = GhosttyConfig()
|
|
config.fontFamily = "reload-\(loadCount)"
|
|
return config
|
|
}
|
|
|
|
let first = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
GhosttyConfig.invalidateLoadCache()
|
|
let second = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
|
|
XCTAssertEqual(loadCount, 2)
|
|
XCTAssertEqual(first.fontFamily, "reload-1")
|
|
XCTAssertEqual(second.fontFamily, "reload-2")
|
|
}
|
|
|
|
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: nil,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 10,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: 0
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
|
currentConfigFileSize: nil,
|
|
currentLegacyConfigFileSize: nil,
|
|
releaseConfigFileSize: 128,
|
|
releaseLegacyConfigFileSize: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
|
currentConfigFileSize: nil,
|
|
currentLegacyConfigFileSize: 64,
|
|
releaseConfigFileSize: 128,
|
|
releaseLegacyConfigFileSize: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
|
currentBundleIdentifier: "com.cmuxterm.app",
|
|
currentConfigFileSize: nil,
|
|
currentLegacyConfigFileSize: nil,
|
|
releaseConfigFileSize: 128,
|
|
releaseLegacyConfigFileSize: nil
|
|
)
|
|
)
|
|
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
|
currentConfigFileSize: nil,
|
|
currentLegacyConfigFileSize: nil,
|
|
releaseConfigFileSize: nil,
|
|
releaseLegacyConfigFileSize: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .unscoped,
|
|
incomingScope: .app
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .app,
|
|
incomingScope: .surface
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .surface
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .app
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .unscoped
|
|
)
|
|
)
|
|
}
|
|
|
|
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .dark,
|
|
currentColorScheme: .light
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: nil,
|
|
currentColorScheme: .dark
|
|
)
|
|
)
|
|
}
|
|
|
|
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .light,
|
|
currentColorScheme: .light
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .dark,
|
|
currentColorScheme: .dark
|
|
)
|
|
)
|
|
}
|
|
|
|
func testScrollLagCaptureRequiresSustainedLag() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 4,
|
|
averageMs: 18,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 6,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 18,
|
|
maxMs: 35,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 18,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testScrollLagCaptureRespectsCooldownWindow() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 12,
|
|
averageMs: 22,
|
|
maxMs: 90,
|
|
thresholdMs: 40,
|
|
nowUptime: 1200,
|
|
lastReportedUptime: 1005,
|
|
cooldown: 300
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 12,
|
|
averageMs: 22,
|
|
maxMs: 90,
|
|
thresholdMs: 40,
|
|
nowUptime: 1406,
|
|
lastReportedUptime: 1005,
|
|
cooldown: 300
|
|
)
|
|
)
|
|
}
|
|
|
|
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
|
|
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testClaudeCodeIntegrationRespectsStoredPreference() {
|
|
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testTelemetryDefaultsToEnabledWhenUnset() {
|
|
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testTelemetryRespectsStoredPreference() {
|
|
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
private func rgb255(_ color: NSColor) -> RGB {
|
|
let srgb = color.usingColorSpace(.sRGB)!
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
return RGB(
|
|
red: Int(round(red * 255)),
|
|
green: Int(round(green * 255)),
|
|
blue: Int(round(blue * 255))
|
|
)
|
|
}
|
|
}
|
|
|
|
final class WorkspaceChromeThemeTests: XCTestCase {
|
|
func testResolvedChromeColorsUsesLightGhosttyBackground() {
|
|
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test color")
|
|
return
|
|
}
|
|
|
|
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
|
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
|
|
XCTAssertNil(colors.borderHex)
|
|
}
|
|
|
|
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
|
|
guard let backgroundColor = NSColor(hex: "#272822") else {
|
|
XCTFail("Expected valid test color")
|
|
return
|
|
}
|
|
|
|
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
|
XCTAssertEqual(colors.backgroundHex, "#272822")
|
|
XCTAssertNil(colors.borderHex)
|
|
}
|
|
}
|
|
|
|
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
|
|
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
|
|
guard let loadedBackground = NSColor(hex: "#112233"),
|
|
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
|
let loadedForeground = NSColor(hex: "#ABCDEF") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
var loaded = GhosttyConfig()
|
|
loaded.backgroundColor = loadedBackground
|
|
loaded.foregroundColor = loadedForeground
|
|
loaded.unfocusedSplitOpacity = 0.42
|
|
|
|
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
|
loadConfig: { loaded },
|
|
defaultBackground: { runtimeBackground }
|
|
)
|
|
|
|
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
|
|
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
|
|
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
|
|
}
|
|
|
|
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
|
|
guard let loadedBackground = NSColor(hex: "#112233"),
|
|
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
|
let explicitOverride = NSColor(hex: "#272822") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
var loaded = GhosttyConfig()
|
|
loaded.backgroundColor = loadedBackground
|
|
|
|
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
|
backgroundOverride: explicitOverride,
|
|
loadConfig: { loaded },
|
|
defaultBackground: { runtimeBackground }
|
|
)
|
|
|
|
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WorkspaceChromeColorTests: XCTestCase {
|
|
func testBonsplitChromeHexIncludesAlphaWhenTranslucent() {
|
|
let color = NSColor(
|
|
srgbRed: 17.0 / 255.0,
|
|
green: 34.0 / 255.0,
|
|
blue: 51.0 / 255.0,
|
|
alpha: 1.0
|
|
)
|
|
|
|
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5)
|
|
XCTAssertEqual(hex, "#1122337F")
|
|
}
|
|
|
|
func testBonsplitChromeHexOmitsAlphaWhenOpaque() {
|
|
let color = NSColor(
|
|
srgbRed: 17.0 / 255.0,
|
|
green: 34.0 / 255.0,
|
|
blue: 51.0 / 255.0,
|
|
alpha: 1.0
|
|
)
|
|
|
|
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0)
|
|
XCTAssertEqual(hex, "#112233")
|
|
}
|
|
}
|
|
|
|
final class WindowTransparencyDecisionTests: XCTestCase {
|
|
private let sidebarBlendModeKey = "sidebarBlendMode"
|
|
private let bgGlassEnabledKey = "bgGlassEnabled"
|
|
|
|
func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() {
|
|
withTemporaryWindowBackgroundDefaults {
|
|
let defaults = UserDefaults.standard
|
|
defaults.set("withinWindow", forKey: sidebarBlendModeKey)
|
|
defaults.set(false, forKey: bgGlassEnabledKey)
|
|
|
|
XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow())
|
|
XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80))
|
|
XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0))
|
|
}
|
|
}
|
|
|
|
func testBehindWindowGlassPathStillControlsTransparentWindowFallback() {
|
|
withTemporaryWindowBackgroundDefaults {
|
|
let defaults = UserDefaults.standard
|
|
defaults.set("behindWindow", forKey: sidebarBlendModeKey)
|
|
defaults.set(true, forKey: bgGlassEnabledKey)
|
|
|
|
let expectedTransparentFallback = !WindowGlassEffect.isAvailable
|
|
XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback)
|
|
XCTAssertEqual(
|
|
cmuxShouldUseClearWindowBackground(for: 1.0),
|
|
expectedTransparentFallback
|
|
)
|
|
}
|
|
}
|
|
|
|
private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) {
|
|
let defaults = UserDefaults.standard
|
|
let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey)
|
|
let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey)
|
|
defer {
|
|
restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults)
|
|
restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults)
|
|
}
|
|
body()
|
|
}
|
|
|
|
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WindowBackgroundSelectionGateTests: XCTestCase {
|
|
func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() {
|
|
let tabId = UUID()
|
|
let activeSelectedTabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: tabId,
|
|
activeSelectedTabId: activeSelectedTabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundFallsBackToActiveSelection() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: UUID(),
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
final class NotificationBurstCoalescerTests: XCTestCase {
|
|
func testSignalsInSameBurstFlushOnce() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush once")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
for _ in 0..<8 {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 1)
|
|
}
|
|
|
|
func testLatestActionWinsWithinBurst() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "latest action flushed")
|
|
var value = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
value = 1
|
|
}
|
|
coalescer.signal {
|
|
value = 2
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(value, 2)
|
|
}
|
|
|
|
func testSignalsAcrossBurstsFlushMultipleTimes() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush twice")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 2)
|
|
}
|
|
}
|
|
|
|
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
|
|
func testSignalCoalescesBurstToLatestBackground() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "coalesced notification")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var postedUserInfos: [[AnyHashable: Any]] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
postedUserInfos.append(userInfo)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
|
|
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedUserInfos.count, 1)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
|
|
"#FDF6E3"
|
|
)
|
|
XCTAssertEqual(
|
|
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
|
|
0.75,
|
|
accuracy: 0.0001
|
|
)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
|
|
2
|
|
)
|
|
XCTAssertEqual(
|
|
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
|
|
"test.light"
|
|
)
|
|
}
|
|
|
|
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "two notifications")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var postedHexes: [String] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
|
postedHexes.append(hex)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
|
|
}
|
|
|
|
private func postedOpacity(from value: Any?) -> Double {
|
|
if let value = value as? Double {
|
|
return value
|
|
}
|
|
if let value = value as? NSNumber {
|
|
return value.doubleValue
|
|
}
|
|
XCTFail("Expected background opacity payload")
|
|
return -1
|
|
}
|
|
}
|
|
|
|
final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|
func testPopReturnsEntriesInLIFOOrder() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 20)
|
|
stack.push(makeSnapshot(index: 1))
|
|
stack.push(makeSnapshot(index: 2))
|
|
stack.push(makeSnapshot(index: 3))
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
func testPushDropsOldestEntriesWhenCapacityExceeded() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 3)
|
|
for index in 1...5 {
|
|
stack.push(makeSnapshot(index: index))
|
|
}
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
|
|
ClosedBrowserPanelRestoreSnapshot(
|
|
workspaceId: UUID(),
|
|
url: URL(string: "https://example.com/\(index)"),
|
|
originalPaneId: UUID(),
|
|
originalTabIndex: index,
|
|
fallbackSplitOrientation: .horizontal,
|
|
fallbackSplitInsertFirst: false,
|
|
fallbackAnchorPaneId: UUID()
|
|
)
|
|
}
|
|
}
|
|
|
|
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
|
|
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
|
|
let projectRoot = findProjectRoot()
|
|
let tabManagerURL = projectRoot.appendingPathComponent("Sources/TabManager.swift")
|
|
let source = try String(contentsOf: tabManagerURL, encoding: .utf8)
|
|
|
|
guard let titleObserverStart = source.range(of: "forName: .ghosttyDidSetTitle"),
|
|
let focusObserverStart = source.range(
|
|
of: "forName: .ghosttyDidFocusSurface",
|
|
range: titleObserverStart.upperBound..<source.endIndex
|
|
) else {
|
|
XCTFail("Failed to locate TabManager notification observer block in Sources/TabManager.swift")
|
|
return
|
|
}
|
|
|
|
let block = String(source[titleObserverStart.lowerBound..<focusObserverStart.lowerBound])
|
|
XCTAssertFalse(
|
|
block.contains("Task {"),
|
|
"""
|
|
The .ghosttyDidSetTitle observer must update model state in the notification callback.
|
|
Using Task can reorder updates and leave titlebar/toolbar one event behind.
|
|
"""
|
|
)
|
|
XCTAssertTrue(
|
|
block.contains("MainActor.assumeIsolated"),
|
|
"Expected .ghosttyDidSetTitle observer to run synchronously on MainActor."
|
|
)
|
|
XCTAssertTrue(
|
|
block.contains("enqueuePanelTitleUpdate"),
|
|
"Expected .ghosttyDidSetTitle observer to enqueue panel title updates."
|
|
)
|
|
}
|
|
|
|
private func findProjectRoot() -> URL {
|
|
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
|
for _ in 0..<10 {
|
|
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
|
if FileManager.default.fileExists(atPath: marker.path) {
|
|
return dir
|
|
}
|
|
dir = dir.deletingLastPathComponent()
|
|
}
|
|
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
|
}
|
|
}
|
|
|
|
final class SocketControlSettingsTests: XCTestCase {
|
|
func testMigrateModeSupportsExpandedSocketModes() {
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
|
|
|
|
// Legacy aliases
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
|
|
}
|
|
|
|
func testSocketModePermissions() {
|
|
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
|
|
}
|
|
|
|
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
|
|
XCTAssertNil(
|
|
SocketControlSettings.envOverrideMode(
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.effectiveMode(
|
|
userMode: .password,
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
),
|
|
.password
|
|
)
|
|
}
|
|
|
|
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux.sock")
|
|
}
|
|
|
|
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.nightly",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
|
|
}
|
|
|
|
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
|
|
}
|
|
|
|
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
|
|
}
|
|
|
|
func testStableReleaseCanOptInToSocketOverride() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
|
|
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
|
|
}
|
|
|
|
func testDefaultSocketPathByChannel() {
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
|
|
"/tmp/cmux.sock"
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
|
|
"/tmp/cmux-nightly.sock"
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
|
|
"/tmp/cmux-debug.sock"
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
|
|
"/tmp/cmux-staging.sock"
|
|
)
|
|
}
|
|
|
|
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
|
XCTAssertTrue(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testUntaggedDebugBundleAllowedWithLaunchTag() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["CMUX_TAG": "tests-v1"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReleaseBuildIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
|
// XCUITest launches the app as a separate process without XCTest env vars.
|
|
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["CMUX_UI_TEST_MODE": "1"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
final class PostHogAnalyticsPropertiesTests: XCTestCase {
|
|
func testDailyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testSuperPropertiesIncludePlatformVersionAndBuild() {
|
|
let properties = PostHogAnalytics.superProperties(
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(properties["app_version"])
|
|
XCTAssertNil(properties["app_build"])
|
|
}
|
|
|
|
func testPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
|
|
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
|
|
XCTAssertNil(superProperties["app_version"])
|
|
XCTAssertNil(superProperties["app_build"])
|
|
|
|
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(dailyProperties["app_version"])
|
|
XCTAssertNil(dailyProperties["app_build"])
|
|
}
|
|
|
|
func testFlushPolicyIncludesDailyAndHourlyActiveEvents() {
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active"))
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active"))
|
|
XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event"))
|
|
}
|
|
}
|
|
|
|
final class GhosttyMouseFocusTests: XCTestCase {
|
|
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: false,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderDuringMouseDrag() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 1,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: false,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: true
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - CJK Font Fallback
|
|
|
|
private func withTempConfig(
|
|
_ contents: String,
|
|
body: (String) -> Void
|
|
) throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let file = dir.appendingPathComponent("config")
|
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
|
body(file.path)
|
|
}
|
|
|
|
// MARK: cjkFontMappings
|
|
|
|
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
|
|
let fonts = Set(mappings.map(\.1))
|
|
let ranges = mappings.map(\.0)
|
|
|
|
XCTAssertTrue(fonts.contains("Hiragino Sans"))
|
|
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
|
|
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
|
|
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
|
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
|
|
let fonts = Set(mappings.map(\.1))
|
|
let ranges = mappings.map(\.0)
|
|
|
|
XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
|
|
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
|
|
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
|
|
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
|
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsPingFangForChinese() {
|
|
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
|
|
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
|
|
|
|
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
|
|
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
|
|
|
|
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
|
|
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
|
|
}
|
|
|
|
func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
|
|
|
|
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
|
|
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)
|
|
|
|
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
|
|
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
|
|
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
|
|
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
|
|
}
|
|
|
|
// MARK: userConfigContainsCJKCodepointMap
|
|
|
|
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
|
|
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"])
|
|
)
|
|
}
|
|
|
|
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]))
|
|
}
|
|
}
|