- Config: sidebar-background supports plain hex (#336699) or light/dark syntax (light:#fbf3db,dark:#103c48) - Config: sidebar-tint-opacity overrides tint opacity - Settings UI: per-scheme color pickers, opacity slider (0-70%), reset - SidebarBackdrop resolves light/dark hex based on @Environment colorScheme - applySidebarAppearanceToUserDefaults guards on rawSidebarBackground presence so UI picks survive appearance toggles when no config is set - Stale light/dark UserDefaults keys cleared when config switches from dual-mode to single or sidebar-background is removed - applyPreset() and Reset Tint clear per-scheme overrides - Debug snapshot (combinedPayload + copySidebarConfig) includes new keys - ColorPicker labels use String(localized:) per localization policy - Opacity slider capped at 0.7 to match debug view vibrancy constraint Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1846 lines
68 KiB
Swift
1846 lines
68 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 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 WindowBackgroundSelectionGateTests: XCTestCase {
|
|
func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() {
|
|
let tabId = UUID()
|
|
let activeSelectedTabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: tabId,
|
|
activeSelectedTabId: activeSelectedTabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundFallsBackToActiveSelection() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: UUID(),
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
final class NotificationBurstCoalescerTests: XCTestCase {
|
|
func testSignalsInSameBurstFlushOnce() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush once")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
for _ in 0..<8 {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 1)
|
|
}
|
|
|
|
func testLatestActionWinsWithinBurst() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "latest action flushed")
|
|
var value = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
value = 1
|
|
}
|
|
coalescer.signal {
|
|
value = 2
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(value, 2)
|
|
}
|
|
|
|
func testSignalsAcrossBurstsFlushMultipleTimes() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush twice")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 2)
|
|
}
|
|
}
|
|
|
|
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
|
|
func testSignalCoalescesBurstToLatestBackground() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "coalesced notification")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var postedUserInfos: [[AnyHashable: Any]] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
postedUserInfos.append(userInfo)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
|
|
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedUserInfos.count, 1)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
|
|
"#FDF6E3"
|
|
)
|
|
XCTAssertEqual(
|
|
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
|
|
0.75,
|
|
accuracy: 0.0001
|
|
)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
|
|
2
|
|
)
|
|
XCTAssertEqual(
|
|
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
|
|
"test.light"
|
|
)
|
|
}
|
|
|
|
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "two notifications")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var postedHexes: [String] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
|
postedHexes.append(hex)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
|
|
}
|
|
|
|
private func postedOpacity(from value: Any?) -> Double {
|
|
if let value = value as? Double {
|
|
return value
|
|
}
|
|
if let value = value as? NSNumber {
|
|
return value.doubleValue
|
|
}
|
|
XCTFail("Expected background opacity payload")
|
|
return -1
|
|
}
|
|
}
|
|
|
|
final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|
func testPopReturnsEntriesInLIFOOrder() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 20)
|
|
stack.push(makeSnapshot(index: 1))
|
|
stack.push(makeSnapshot(index: 2))
|
|
stack.push(makeSnapshot(index: 3))
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
func testPushDropsOldestEntriesWhenCapacityExceeded() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 3)
|
|
for index in 1...5 {
|
|
stack.push(makeSnapshot(index: index))
|
|
}
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
|
|
ClosedBrowserPanelRestoreSnapshot(
|
|
workspaceId: UUID(),
|
|
url: URL(string: "https://example.com/\(index)"),
|
|
originalPaneId: UUID(),
|
|
originalTabIndex: index,
|
|
fallbackSplitOrientation: .horizontal,
|
|
fallbackSplitInsertFirst: false,
|
|
fallbackAnchorPaneId: UUID()
|
|
)
|
|
}
|
|
}
|
|
|
|
final class SocketControlSettingsTests: XCTestCase {
|
|
func testMigrateModeSupportsExpandedSocketModes() {
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
|
|
|
|
// Legacy aliases
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
|
|
}
|
|
|
|
func testSocketModePermissions() {
|
|
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
|
|
}
|
|
|
|
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
|
|
XCTAssertNil(
|
|
SocketControlSettings.envOverrideMode(
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.effectiveMode(
|
|
userMode: .password,
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
),
|
|
.password
|
|
)
|
|
}
|
|
|
|
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
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 PostHogAnalyticsPropertiesTests: XCTestCase {
|
|
func testDailyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testSuperPropertiesIncludePlatformVersionAndBuild() {
|
|
let properties = PostHogAnalytics.superProperties(
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(properties["app_version"])
|
|
XCTAssertNil(properties["app_build"])
|
|
}
|
|
|
|
func testPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
|
|
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
|
|
XCTAssertNil(superProperties["app_version"])
|
|
XCTAssertNil(superProperties["app_build"])
|
|
|
|
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(dailyProperties["app_version"])
|
|
XCTAssertNil(dailyProperties["app_build"])
|
|
}
|
|
|
|
func testFlushPolicyIncludesDailyAndHourlyActiveEvents() {
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active"))
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active"))
|
|
XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event"))
|
|
}
|
|
}
|
|
|
|
final class GhosttyMouseFocusTests: XCTestCase {
|
|
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: false,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderDuringMouseDrag() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 1,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: false,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: true
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - CJK Font Fallback
|
|
|
|
private func withTempConfig(
|
|
_ contents: String,
|
|
body: (String) -> Void
|
|
) throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let file = dir.appendingPathComponent("config")
|
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
|
body(file.path)
|
|
}
|
|
|
|
// MARK: cjkFontMappings
|
|
|
|
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
|
|
let fonts = Set(mappings.map(\.1))
|
|
let ranges = mappings.map(\.0)
|
|
|
|
XCTAssertTrue(fonts.contains("Hiragino Sans"))
|
|
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
|
|
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
|
|
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
|
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
|
|
let fonts = Set(mappings.map(\.1))
|
|
let ranges = mappings.map(\.0)
|
|
|
|
XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
|
|
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
|
|
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
|
|
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
|
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsPingFangForChinese() {
|
|
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
|
|
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
|
|
|
|
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
|
|
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
|
|
|
|
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
|
|
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
|
|
}
|
|
|
|
func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
|
|
|
|
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
|
|
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)
|
|
|
|
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
|
|
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
|
|
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
|
|
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
|
|
}
|
|
|
|
// MARK: userConfigContainsCJKCodepointMap
|
|
|
|
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
|
|
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
|
|
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
|
|
XCTAssertFalse(
|
|
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
|
|
)
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "font-family = Menlo\nconfig-file = \(included.path)\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "config-file = fonts.conf\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "config-file = \(included.path)?\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let fileA = dir.appendingPathComponent("a.conf")
|
|
let fileB = dir.appendingPathComponent("b.conf")
|
|
try "config-file = \(fileB.path)\n"
|
|
.write(to: fileA, atomically: true, encoding: .utf8)
|
|
try "config-file = \(fileA.path)\n"
|
|
.write(to: fileB, atomically: true, encoding: .utf8)
|
|
|
|
// Should not hang; should return false since neither file has font-codepoint-map
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
|
|
}
|
|
}
|
|
|
|
final class 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)
|
|
}
|
|
|
|
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
|
|
let fileManager = FileManager.default
|
|
let root = fileManager.temporaryDirectory
|
|
.appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)")
|
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: root) }
|
|
|
|
let userZdotdir = root.appendingPathComponent("zdotdir")
|
|
try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true)
|
|
try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
|
|
|
|
let repoRoot = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration")
|
|
let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src")
|
|
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
process.arguments = [
|
|
"-i",
|
|
"-c",
|
|
"(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " +
|
|
"print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " +
|
|
"PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\""
|
|
]
|
|
process.environment = [
|
|
"HOME": root.path,
|
|
"TERM": "xterm-256color",
|
|
"SHELL": "/bin/zsh",
|
|
"USER": NSUserName(),
|
|
"ZDOTDIR": cmuxZdotdir.path,
|
|
"CMUX_ZSH_ZDOTDIR": userZdotdir.path,
|
|
"CMUX_SHELL_INTEGRATION": "0",
|
|
"GHOSTTY_RESOURCES_DIR": ghosttyResources.path,
|
|
]
|
|
if cmuxLoadGhosttyIntegration {
|
|
process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
|
}
|
|
|
|
let stdout = Pipe()
|
|
let stderr = Pipe()
|
|
process.standardOutput = stdout
|
|
process.standardError = stderr
|
|
|
|
try process.run()
|
|
let deadline = Date().addingTimeInterval(5)
|
|
while process.isRunning && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
if process.isRunning {
|
|
process.terminate()
|
|
process.waitUntilExit()
|
|
XCTFail("Timed out waiting for zsh to exit")
|
|
}
|
|
|
|
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
|
|
XCTAssertEqual(process.terminationStatus, 0, error)
|
|
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|