* fix: skip Korean from CJK font-codepoint-map auto-injection The automatic CJK font-codepoint-map injection (PR #1017) maps Korean ranges to Apple SD Gothic Neo, which has a different style/weight from the primary terminal font. This overrides Ghostty's native CTFontCreateForString fallback, which dynamically selects a better-matching font for Hangul. Ghostty itself (ghostty-org/ghostty) has no hardcoded CJK font names and relies entirely on CTFontCreateForString for fallback. For Korean, this native fallback produces visually consistent results with the primary font. Remove the Korean branch from cjkFontMappings() so Ghostty's native fallback handles Hangul rendering. Japanese and Chinese mappings are unaffected. * test: update CJK font mapping tests for Korean removal - testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean → renamed to testCJKFontMappingsReturnsNilForKoreanOnly → asserts nil since Korean is no longer auto-mapped - testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges → renamed to testCJKFontMappingsMultiLanguageSkipsKorean → asserts no Apple SD Gothic Neo mapping exists → Japanese mappings remain unchanged --------- Co-authored-by: dante-ad-shield <danate@ad-shield.io>
2667 lines
99 KiB
Swift
2667 lines
99 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import WebKit
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
final class SidebarPathFormatterTests: XCTestCase {
|
|
func testShortenedPathReplacesExactHomeDirectory() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/Users/example",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"~"
|
|
)
|
|
}
|
|
|
|
func testShortenedPathReplacesHomeDirectoryPrefix() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/Users/example/projects/cmux",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"~/projects/cmux"
|
|
)
|
|
}
|
|
|
|
func testShortenedPathLeavesExternalPathUnchanged() {
|
|
XCTAssertEqual(
|
|
SidebarPathFormatter.shortenedPath(
|
|
"/tmp/cmux",
|
|
homeDirectoryPath: "/Users/example"
|
|
),
|
|
"/tmp/cmux"
|
|
)
|
|
}
|
|
}
|
|
|
|
final class GhosttyConfigTests: XCTestCase {
|
|
private struct RGB: Equatable {
|
|
let red: Int
|
|
let green: Int
|
|
let blue: Int
|
|
}
|
|
|
|
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
|
|
let resolved = GhosttyConfig.resolveThemeName(
|
|
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
|
preferredColorScheme: .light
|
|
)
|
|
|
|
XCTAssertEqual(resolved, "Builtin Solarized Light")
|
|
}
|
|
|
|
func testResolveThemeNamePrefersDarkEntryForPairedTheme() {
|
|
let resolved = GhosttyConfig.resolveThemeName(
|
|
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
|
preferredColorScheme: .dark
|
|
)
|
|
|
|
XCTAssertEqual(resolved, "Builtin Solarized Dark")
|
|
}
|
|
|
|
func testThemeNameCandidatesIncludeBuiltinAliasForms() {
|
|
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Light")
|
|
XCTAssertEqual(candidates.first, "Builtin Solarized Light")
|
|
XCTAssertTrue(candidates.contains("Solarized Light"))
|
|
XCTAssertTrue(candidates.contains("iTerm2 Solarized Light"))
|
|
}
|
|
|
|
func testThemeNameCandidatesMapSolarizedDarkToITerm2Alias() {
|
|
let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Dark")
|
|
XCTAssertTrue(candidates.contains("Solarized Dark"))
|
|
XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark"))
|
|
}
|
|
|
|
func testThemeSearchPathsIncludeXDGDataDirsThemes() {
|
|
let pathA = "/tmp/cmux-theme-a"
|
|
let pathB = "/tmp/cmux-theme-b"
|
|
let paths = GhosttyConfig.themeSearchPaths(
|
|
forThemeName: "Solarized Light",
|
|
environment: ["XDG_DATA_DIRS": "\(pathA):\(pathB)"],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertTrue(paths.contains("\(pathA)/ghostty/themes/Solarized Light"))
|
|
XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light"))
|
|
}
|
|
|
|
func testLoadThemeResolvesPairedThemeValueByColorScheme() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
try """
|
|
background = #fdf6e3
|
|
foreground = #657b83
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Light Theme"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
try """
|
|
background = #002b36
|
|
foreground = #93a1a1
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Dark Theme"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
var lightConfig = GhosttyConfig()
|
|
lightConfig.loadTheme(
|
|
"light:Light Theme,dark:Dark Theme",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil,
|
|
preferredColorScheme: .light
|
|
)
|
|
XCTAssertEqual(rgb255(lightConfig.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
|
|
|
var darkConfig = GhosttyConfig()
|
|
darkConfig.loadTheme(
|
|
"light:Light Theme,dark:Dark Theme",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil,
|
|
preferredColorScheme: .dark
|
|
)
|
|
XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54))
|
|
}
|
|
|
|
func testParseBackgroundOpacityReadsConfigValue() {
|
|
var config = GhosttyConfig()
|
|
config.parse("background-opacity = 0.42")
|
|
XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001)
|
|
}
|
|
|
|
func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
let themePath = themesDir.appendingPathComponent("Solarized Light")
|
|
let themeContents = """
|
|
background = #fdf6e3
|
|
foreground = #657b83
|
|
"""
|
|
try themeContents.write(to: themePath, atomically: true, encoding: .utf8)
|
|
|
|
var config = GhosttyConfig()
|
|
config.loadTheme(
|
|
"Builtin Solarized Light",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
|
}
|
|
|
|
func testLoadThemeResolvesITerm2SolarizedLightAliasToLegacyThemeName() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-solarized-light-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
try """
|
|
background = #fdf6e3
|
|
foreground = #657b83
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Solarized Light"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
var config = GhosttyConfig()
|
|
config.loadTheme(
|
|
"iTerm2 Solarized Light",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
|
}
|
|
|
|
func testLoadThemeResolvesITerm2SolarizedDarkAliasToLegacyThemeName() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-ghostty-solarized-dark-\(UUID().uuidString)")
|
|
let themesDir = root.appendingPathComponent("themes")
|
|
try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
try """
|
|
background = #002b36
|
|
foreground = #93a1a1
|
|
""".write(
|
|
to: themesDir.appendingPathComponent("Solarized Dark"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
|
|
var config = GhosttyConfig()
|
|
config.loadTheme(
|
|
"iTerm2 Solarized Dark",
|
|
environment: ["GHOSTTY_RESOURCES_DIR": root.path],
|
|
bundleResourceURL: nil
|
|
)
|
|
|
|
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 0, green: 43, blue: 54))
|
|
}
|
|
|
|
func testLoadCachesPerColorScheme() {
|
|
GhosttyConfig.invalidateLoadCache()
|
|
defer { GhosttyConfig.invalidateLoadCache() }
|
|
|
|
var loadCount = 0
|
|
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in
|
|
loadCount += 1
|
|
var config = GhosttyConfig()
|
|
config.fontFamily = "\(scheme)-\(loadCount)"
|
|
return config
|
|
}
|
|
|
|
let lightFirst = GhosttyConfig.load(
|
|
preferredColorScheme: .light,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
let lightSecond = GhosttyConfig.load(
|
|
preferredColorScheme: .light,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
let darkFirst = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
|
|
XCTAssertEqual(loadCount, 2)
|
|
XCTAssertEqual(lightFirst.fontFamily, "light-1")
|
|
XCTAssertEqual(lightSecond.fontFamily, "light-1")
|
|
XCTAssertEqual(darkFirst.fontFamily, "dark-2")
|
|
}
|
|
|
|
func testLoadCacheInvalidationForcesReload() {
|
|
GhosttyConfig.invalidateLoadCache()
|
|
defer { GhosttyConfig.invalidateLoadCache() }
|
|
|
|
var loadCount = 0
|
|
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in
|
|
loadCount += 1
|
|
var config = GhosttyConfig()
|
|
config.fontFamily = "reload-\(loadCount)"
|
|
return config
|
|
}
|
|
|
|
let first = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
GhosttyConfig.invalidateLoadCache()
|
|
let second = GhosttyConfig.load(
|
|
preferredColorScheme: .dark,
|
|
loadFromDisk: loadFromDisk
|
|
)
|
|
|
|
XCTAssertEqual(loadCount, 2)
|
|
XCTAssertEqual(first.fontFamily, "reload-1")
|
|
XCTAssertEqual(second.fontFamily, "reload-2")
|
|
}
|
|
|
|
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
}
|
|
|
|
func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: nil,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 10,
|
|
legacyConfigFileSize: 42
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: 0
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: 0,
|
|
legacyConfigFileSize: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testCmuxAppSupportConfigURLsUseReleaseConfigForDebugBundleWithoutCurrentConfig() throws {
|
|
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
|
let releaseConfigURL = try writeAppSupportConfig(
|
|
appSupportDirectory: appSupportDirectory,
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
filename: "config",
|
|
contents: "font-size = 13\n"
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
|
appSupportDirectory: appSupportDirectory
|
|
),
|
|
[releaseConfigURL]
|
|
)
|
|
}
|
|
}
|
|
|
|
func testCmuxAppSupportConfigURLsPreferCurrentBundleConfigWhenPresent() throws {
|
|
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
|
_ = try writeAppSupportConfig(
|
|
appSupportDirectory: appSupportDirectory,
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
filename: "config",
|
|
contents: "font-size = 13\n"
|
|
)
|
|
let currentConfigURL = try writeAppSupportConfig(
|
|
appSupportDirectory: appSupportDirectory,
|
|
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
|
filename: "config.ghostty",
|
|
contents: "font-size = 14\n"
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
|
appSupportDirectory: appSupportDirectory
|
|
),
|
|
[currentConfigURL]
|
|
)
|
|
}
|
|
}
|
|
|
|
func testCmuxAppSupportConfigURLsSkipReleaseFallbackForNonDebugBundle() throws {
|
|
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
|
_ = try writeAppSupportConfig(
|
|
appSupportDirectory: appSupportDirectory,
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
filename: "config",
|
|
contents: "font-size = 13\n"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: "com.example.other-app",
|
|
appSupportDirectory: appSupportDirectory
|
|
).isEmpty
|
|
)
|
|
}
|
|
}
|
|
|
|
func testCmuxAppSupportConfigURLsIgnoreMissingOrEmptyFiles() throws {
|
|
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
|
_ = try writeAppSupportConfig(
|
|
appSupportDirectory: appSupportDirectory,
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
filename: "config.ghostty",
|
|
contents: ""
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
GhosttyApp.cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
|
appSupportDirectory: appSupportDirectory
|
|
).isEmpty
|
|
)
|
|
}
|
|
}
|
|
|
|
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .unscoped,
|
|
incomingScope: .app
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .app,
|
|
incomingScope: .surface
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .surface
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .app
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
|
currentScope: .surface,
|
|
incomingScope: .unscoped
|
|
)
|
|
)
|
|
}
|
|
|
|
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .dark,
|
|
currentColorScheme: .light
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: nil,
|
|
currentColorScheme: .dark
|
|
)
|
|
)
|
|
}
|
|
|
|
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .light,
|
|
currentColorScheme: .light
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: .dark,
|
|
currentColorScheme: .dark
|
|
)
|
|
)
|
|
}
|
|
|
|
func testScrollLagCaptureRequiresSustainedLag() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 4,
|
|
averageMs: 18,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 6,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 18,
|
|
maxMs: 35,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 10,
|
|
averageMs: 18,
|
|
maxMs: 85,
|
|
thresholdMs: 40,
|
|
nowUptime: 1000,
|
|
lastReportedUptime: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testScrollLagCaptureRespectsCooldownWindow() {
|
|
XCTAssertFalse(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 12,
|
|
averageMs: 22,
|
|
maxMs: 90,
|
|
thresholdMs: 40,
|
|
nowUptime: 1200,
|
|
lastReportedUptime: 1005,
|
|
cooldown: 300
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyApp.shouldCaptureScrollLagEvent(
|
|
samples: 12,
|
|
averageMs: 22,
|
|
maxMs: 90,
|
|
thresholdMs: 40,
|
|
nowUptime: 1406,
|
|
lastReportedUptime: 1005,
|
|
cooldown: 300
|
|
)
|
|
)
|
|
}
|
|
|
|
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
|
|
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testClaudeCodeIntegrationRespectsStoredPreference() {
|
|
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set(true, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
|
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testTelemetryDefaultsToEnabledWhenUnset() {
|
|
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testTelemetryRespectsStoredPreference() {
|
|
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated user defaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
|
XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
private func rgb255(_ color: NSColor) -> RGB {
|
|
let srgb = color.usingColorSpace(.sRGB)!
|
|
var red: CGFloat = 0
|
|
var green: CGFloat = 0
|
|
var blue: CGFloat = 0
|
|
var alpha: CGFloat = 0
|
|
srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
|
return RGB(
|
|
red: Int(round(red * 255)),
|
|
green: Int(round(green * 255)),
|
|
blue: Int(round(blue * 255))
|
|
)
|
|
}
|
|
|
|
private func withTemporaryAppSupportDirectory(
|
|
_ body: (URL) throws -> Void
|
|
) throws {
|
|
let fileManager = FileManager.default
|
|
let directory = fileManager.temporaryDirectory
|
|
.appendingPathComponent("cmux-app-support-\(UUID().uuidString)", isDirectory: true)
|
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: directory) }
|
|
try body(directory)
|
|
}
|
|
|
|
private func writeAppSupportConfig(
|
|
appSupportDirectory: URL,
|
|
bundleIdentifier: String,
|
|
filename: String,
|
|
contents: String
|
|
) throws -> URL {
|
|
let fileManager = FileManager.default
|
|
let bundleDirectory = appSupportDirectory
|
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
|
try fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true)
|
|
|
|
let configURL = bundleDirectory.appendingPathComponent(filename, isDirectory: false)
|
|
try contents.write(to: configURL, atomically: true, encoding: .utf8)
|
|
return configURL
|
|
}
|
|
}
|
|
|
|
final class WorkspaceChromeThemeTests: XCTestCase {
|
|
func testResolvedChromeColorsUsesLightGhosttyBackground() {
|
|
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test color")
|
|
return
|
|
}
|
|
|
|
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
|
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
|
|
XCTAssertNil(colors.borderHex)
|
|
}
|
|
|
|
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
|
|
guard let backgroundColor = NSColor(hex: "#272822") else {
|
|
XCTFail("Expected valid test color")
|
|
return
|
|
}
|
|
|
|
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
|
XCTAssertEqual(colors.backgroundHex, "#272822")
|
|
XCTAssertNil(colors.borderHex)
|
|
}
|
|
}
|
|
|
|
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
|
|
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
|
|
guard let loadedBackground = NSColor(hex: "#112233"),
|
|
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
|
let loadedForeground = NSColor(hex: "#ABCDEF") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
var loaded = GhosttyConfig()
|
|
loaded.backgroundColor = loadedBackground
|
|
loaded.foregroundColor = loadedForeground
|
|
loaded.unfocusedSplitOpacity = 0.42
|
|
|
|
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
|
loadConfig: { loaded },
|
|
defaultBackground: { runtimeBackground }
|
|
)
|
|
|
|
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
|
|
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
|
|
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
|
|
}
|
|
|
|
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
|
|
guard let loadedBackground = NSColor(hex: "#112233"),
|
|
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
|
let explicitOverride = NSColor(hex: "#272822") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
var loaded = GhosttyConfig()
|
|
loaded.backgroundColor = loadedBackground
|
|
|
|
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
|
backgroundOverride: explicitOverride,
|
|
loadConfig: { loaded },
|
|
defaultBackground: { runtimeBackground }
|
|
)
|
|
|
|
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WorkspaceChromeColorTests: XCTestCase {
|
|
func testBonsplitChromeHexIncludesAlphaWhenTranslucent() {
|
|
let color = NSColor(
|
|
srgbRed: 17.0 / 255.0,
|
|
green: 34.0 / 255.0,
|
|
blue: 51.0 / 255.0,
|
|
alpha: 1.0
|
|
)
|
|
|
|
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5)
|
|
XCTAssertEqual(hex, "#1122337F")
|
|
}
|
|
|
|
func testBonsplitChromeHexOmitsAlphaWhenOpaque() {
|
|
let color = NSColor(
|
|
srgbRed: 17.0 / 255.0,
|
|
green: 34.0 / 255.0,
|
|
blue: 51.0 / 255.0,
|
|
alpha: 1.0
|
|
)
|
|
|
|
let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0)
|
|
XCTAssertEqual(hex, "#112233")
|
|
}
|
|
}
|
|
|
|
final class WindowTransparencyDecisionTests: XCTestCase {
|
|
private let sidebarBlendModeKey = "sidebarBlendMode"
|
|
private let bgGlassEnabledKey = "bgGlassEnabled"
|
|
|
|
func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() {
|
|
withTemporaryWindowBackgroundDefaults {
|
|
let defaults = UserDefaults.standard
|
|
defaults.set("withinWindow", forKey: sidebarBlendModeKey)
|
|
defaults.set(false, forKey: bgGlassEnabledKey)
|
|
|
|
XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow())
|
|
XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80))
|
|
XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0))
|
|
}
|
|
}
|
|
|
|
func testBehindWindowGlassPathStillControlsTransparentWindowFallback() {
|
|
withTemporaryWindowBackgroundDefaults {
|
|
let defaults = UserDefaults.standard
|
|
defaults.set("behindWindow", forKey: sidebarBlendModeKey)
|
|
defaults.set(true, forKey: bgGlassEnabledKey)
|
|
|
|
let expectedTransparentFallback = !WindowGlassEffect.isAvailable
|
|
XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback)
|
|
XCTAssertEqual(
|
|
cmuxShouldUseClearWindowBackground(for: 1.0),
|
|
expectedTransparentFallback
|
|
)
|
|
}
|
|
}
|
|
|
|
private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) {
|
|
let defaults = UserDefaults.standard
|
|
let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey)
|
|
let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey)
|
|
defer {
|
|
restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults)
|
|
restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults)
|
|
}
|
|
body()
|
|
}
|
|
|
|
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
|
|
if let value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WorkspaceRemoteDaemonManifestTests: XCTestCase {
|
|
func testParsesEmbeddedRemoteDaemonManifestJSON() throws {
|
|
let manifestJSON = """
|
|
{
|
|
"schemaVersion": 1,
|
|
"appVersion": "0.62.0",
|
|
"releaseTag": "v0.62.0",
|
|
"releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0",
|
|
"checksumsAssetName": "cmuxd-remote-checksums.txt",
|
|
"checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt",
|
|
"entries": [
|
|
{
|
|
"goOS": "linux",
|
|
"goArch": "amd64",
|
|
"assetName": "cmuxd-remote-linux-amd64",
|
|
"downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64",
|
|
"sha256": "abc123"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
let manifest = Workspace.remoteDaemonManifest(from: [
|
|
Workspace.remoteDaemonManifestInfoKey: manifestJSON,
|
|
])
|
|
|
|
XCTAssertEqual(manifest?.releaseTag, "v0.62.0")
|
|
XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64")
|
|
}
|
|
|
|
func testRemoteDaemonCachePathIsVersionedByPlatform() throws {
|
|
let url = try Workspace.remoteDaemonCachedBinaryURL(
|
|
version: "0.62.0",
|
|
goOS: "linux",
|
|
goArch: "arm64"
|
|
)
|
|
|
|
XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/"))
|
|
XCTAssertEqual(url.lastPathComponent, "cmuxd-remote")
|
|
}
|
|
}
|
|
|
|
final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
|
|
func testRewritesLoopbackAliasHostHeadersToLocalhost() {
|
|
let original = Data(
|
|
(
|
|
"GET /demo HTTP/1.1\r\n" +
|
|
"Host: cmux-loopback.localtest.me:3000\r\n" +
|
|
"Origin: http://cmux-loopback.localtest.me:3000\r\n" +
|
|
"Referer: http://cmux-loopback.localtest.me:3000/app\r\n" +
|
|
"\r\n"
|
|
).utf8
|
|
)
|
|
|
|
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
|
|
data: original,
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
|
|
let text = String(decoding: rewritten, as: UTF8.self)
|
|
XCTAssertTrue(text.contains("Host: localhost:3000"))
|
|
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
|
|
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
|
|
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
|
|
}
|
|
|
|
func testRewritesAbsoluteFormRequestLineForLoopbackAlias() {
|
|
let original = Data(
|
|
(
|
|
"GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" +
|
|
"Host: cmux-loopback.localtest.me:3000\r\n" +
|
|
"\r\n"
|
|
).utf8
|
|
)
|
|
|
|
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
|
|
data: original,
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
|
|
let text = String(decoding: rewritten, as: UTF8.self)
|
|
XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n"))
|
|
XCTAssertTrue(text.contains("Host: localhost:3000"))
|
|
}
|
|
|
|
func testLeavesNonHTTPPayloadUntouched() {
|
|
let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00])
|
|
let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
|
|
data: original,
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
XCTAssertEqual(rewritten, original)
|
|
}
|
|
|
|
func testBuffersSplitLoopbackAliasHeadersUntilFullRequestArrives() {
|
|
var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter(
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
|
|
let firstChunk = Data(
|
|
(
|
|
"GET /demo HTTP/1.1\r\n" +
|
|
"Host: cmux-loop"
|
|
).utf8
|
|
)
|
|
let secondChunk = Data(
|
|
(
|
|
"back.localtest.me:3000\r\n" +
|
|
"Origin: http://cmux-loopback.localtest.me:3000\r\n" +
|
|
"Referer: http://cmux-loopback.localtest.me:3000/app\r\n" +
|
|
"\r\n" +
|
|
"body=1"
|
|
).utf8
|
|
)
|
|
|
|
let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false)
|
|
let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: false)
|
|
|
|
XCTAssertTrue(firstOutput.isEmpty)
|
|
|
|
let text = String(decoding: secondOutput, as: UTF8.self)
|
|
XCTAssertTrue(text.contains("Host: localhost:3000"))
|
|
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
|
|
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
|
|
XCTAssertTrue(text.hasSuffix("\r\n\r\nbody=1"))
|
|
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
|
|
}
|
|
|
|
func testFlushesBufferedLoopbackAliasHeadersOnEOFWhenHeadersRemainIncomplete() {
|
|
var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter(
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
|
|
let firstChunk = Data(
|
|
(
|
|
"GET /demo HTTP/1.1\r\n" +
|
|
"Host: cmux-loop"
|
|
).utf8
|
|
)
|
|
let secondChunk = Data(
|
|
(
|
|
"back.localtest.me:3000\r\n" +
|
|
"Origin: http://cmux-loopback.localtest.me:3000\r\n" +
|
|
"Referer: http://cmux-loopback.localtest.me:3000/app\r\n" +
|
|
"body=1"
|
|
).utf8
|
|
)
|
|
|
|
let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false)
|
|
let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: true)
|
|
let thirdOutput = streamRewriter.rewriteNextChunk(Data(), eof: true)
|
|
|
|
XCTAssertTrue(firstOutput.isEmpty)
|
|
|
|
let text = String(decoding: secondOutput, as: UTF8.self)
|
|
XCTAssertTrue(text.contains("Host: localhost:3000"))
|
|
XCTAssertTrue(text.contains("Origin: http://localhost:3000"))
|
|
XCTAssertTrue(text.contains("Referer: http://localhost:3000/app"))
|
|
XCTAssertTrue(text.hasSuffix("\r\nbody=1"))
|
|
XCTAssertFalse(text.contains("cmux-loopback.localtest.me"))
|
|
XCTAssertTrue(thirdOutput.isEmpty)
|
|
}
|
|
|
|
func testRewritesLoopbackResponseHeadersBackToAlias() {
|
|
let original = Data(
|
|
(
|
|
"HTTP/1.1 302 Found\r\n" +
|
|
"Location: http://localhost:3000/login\r\n" +
|
|
"Access-Control-Allow-Origin: http://localhost:3000\r\n" +
|
|
"Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" +
|
|
"\r\n"
|
|
).utf8
|
|
)
|
|
|
|
let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded(
|
|
data: original,
|
|
aliasHost: "cmux-loopback.localtest.me"
|
|
)
|
|
|
|
let text = String(decoding: rewritten, as: UTF8.self)
|
|
XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login"))
|
|
XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000"))
|
|
XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/"))
|
|
}
|
|
}
|
|
|
|
final class GhosttyTerminalStartupEnvironmentTests: XCTestCase {
|
|
func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() {
|
|
let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)"
|
|
let merged = TerminalSurface.mergedStartupEnvironment(
|
|
base: [
|
|
"PATH": "/usr/bin",
|
|
"CMUX_SURFACE_ID": "managed-surface"
|
|
],
|
|
protectedKeys: ["PATH", "CMUX_SURFACE_ID"],
|
|
additionalEnvironment: [
|
|
SessionScrollbackReplayStore.environmentKey: replayPath
|
|
],
|
|
initialEnvironmentOverrides: [
|
|
"CMUX_INITIAL_ENV_TOKEN": "token-123"
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(merged[SessionScrollbackReplayStore.environmentKey], replayPath)
|
|
XCTAssertEqual(merged["CMUX_INITIAL_ENV_TOKEN"], "token-123")
|
|
}
|
|
|
|
func testMergedStartupEnvironmentProtectsManagedKeysOnly() {
|
|
let merged = TerminalSurface.mergedStartupEnvironment(
|
|
base: [
|
|
"PATH": "/usr/bin",
|
|
"CMUX_SURFACE_ID": "managed-surface"
|
|
],
|
|
protectedKeys: ["PATH", "CMUX_SURFACE_ID"],
|
|
additionalEnvironment: [
|
|
"CMUX_SURFACE_ID": "user-surface",
|
|
"CUSTOM_FLAG": "1"
|
|
],
|
|
initialEnvironmentOverrides: [
|
|
"PATH": "/tmp/bin",
|
|
"CMUX_SURFACE_ID": "override-surface"
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(merged["PATH"], "/usr/bin")
|
|
XCTAssertEqual(merged["CMUX_SURFACE_ID"], "managed-surface")
|
|
XCTAssertEqual(merged["CUSTOM_FLAG"], "1")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class BrowserPanelRemoteStoreTests: XCTestCase {
|
|
func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
|
|
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
|
|
let remoteWorkspaceId = UUID()
|
|
let firstRemotePanel = BrowserPanel(
|
|
workspaceId: remoteWorkspaceId,
|
|
isRemoteWorkspace: true,
|
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
|
)
|
|
let secondRemotePanel = BrowserPanel(
|
|
workspaceId: remoteWorkspaceId,
|
|
isRemoteWorkspace: true,
|
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
|
)
|
|
|
|
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
|
XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
|
XCTAssertTrue(
|
|
firstRemotePanel.webView.configuration.websiteDataStore ===
|
|
secondRemotePanel.webView.configuration.websiteDataStore
|
|
)
|
|
}
|
|
|
|
func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() {
|
|
let remoteWorkspaceId = UUID()
|
|
let url = URL(string: "http://localhost:3000/demo")!
|
|
let panel = BrowserPanel(
|
|
workspaceId: remoteWorkspaceId,
|
|
initialURL: url,
|
|
isRemoteWorkspace: true,
|
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
|
)
|
|
|
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
|
XCTAssertNil(panel.webView.url)
|
|
|
|
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
|
|
|
|
let deadline = Date().addingTimeInterval(1.0)
|
|
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
|
|
|
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
|
XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me")
|
|
}
|
|
|
|
func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() {
|
|
let remoteWorkspaceId = UUID()
|
|
let url = URL(string: "https://localhost:3443/demo")!
|
|
let panel = BrowserPanel(
|
|
workspaceId: remoteWorkspaceId,
|
|
initialURL: url,
|
|
isRemoteWorkspace: true,
|
|
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
|
|
)
|
|
|
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
|
XCTAssertNil(panel.webView.url)
|
|
|
|
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
|
|
|
|
let deadline = Date().addingTimeInterval(1.0)
|
|
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
|
|
|
|
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
|
|
XCTAssertEqual(panel.webView.url?.host, "localhost")
|
|
}
|
|
|
|
func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws {
|
|
let source = Workspace()
|
|
let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first)
|
|
let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
|
|
let localStore = sourceBrowser.webView.configuration.websiteDataStore
|
|
XCTAssertTrue(localStore === WKWebsiteDataStore.default())
|
|
|
|
let destination = Workspace()
|
|
destination.configureRemoteConnection(
|
|
WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: 22,
|
|
identityFile: nil,
|
|
sshOptions: [],
|
|
localProxyPort: nil,
|
|
relayPort: 64001,
|
|
relayID: "relay-store-dest",
|
|
relayToken: String(repeating: "a", count: 64),
|
|
localSocketPath: "/tmp/cmux-store-dest.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
),
|
|
autoConnect: false
|
|
)
|
|
let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first)
|
|
let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false))
|
|
let destinationStore = destinationBrowser.webView.configuration.websiteDataStore
|
|
XCTAssertFalse(destinationStore === WKWebsiteDataStore.default())
|
|
|
|
let detached = try XCTUnwrap(source.detachSurface(panelId: sourceBrowser.id))
|
|
let attachedPanelId = try XCTUnwrap(
|
|
destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false)
|
|
)
|
|
let movedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel)
|
|
|
|
XCTAssertTrue(movedBrowser.webView.configuration.websiteDataStore === destinationStore)
|
|
XCTAssertFalse(movedBrowser.webView.configuration.websiteDataStore === localStore)
|
|
}
|
|
|
|
func testBrowserMoveOutOfRemoteWorkspaceRestoresDefaultWebsiteDataStore() throws {
|
|
let source = Workspace()
|
|
source.configureRemoteConnection(
|
|
WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: 22,
|
|
identityFile: nil,
|
|
sshOptions: [],
|
|
localProxyPort: nil,
|
|
relayPort: 64002,
|
|
relayID: "relay-store-source",
|
|
relayToken: String(repeating: "b", count: 64),
|
|
localSocketPath: "/tmp/cmux-store-source.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
),
|
|
autoConnect: false
|
|
)
|
|
let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first)
|
|
let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
|
|
let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false))
|
|
let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore
|
|
XCTAssertFalse(remoteStore === WKWebsiteDataStore.default())
|
|
|
|
let destination = Workspace()
|
|
let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first)
|
|
let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id))
|
|
let attachedPanelId = try XCTUnwrap(
|
|
destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false)
|
|
)
|
|
let attachedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel)
|
|
|
|
XCTAssertTrue(attachedBrowser.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
|
|
XCTAssertTrue(remainingRemoteBrowser.webView.configuration.websiteDataStore === remoteStore)
|
|
XCTAssertFalse(remainingRemoteBrowser.webView.configuration.websiteDataStore === attachedBrowser.webView.configuration.websiteDataStore)
|
|
}
|
|
|
|
func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws {
|
|
let workspace = Workspace()
|
|
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
|
|
let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId)
|
|
let configuration = WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: nil,
|
|
identityFile: nil,
|
|
sshOptions: [],
|
|
localProxyPort: nil,
|
|
relayPort: 64000,
|
|
relayID: "relay-test",
|
|
relayToken: String(repeating: "a", count: 64),
|
|
localSocketPath: "/tmp/cmux-test.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
)
|
|
|
|
workspace.configureRemoteConnection(configuration, autoConnect: false)
|
|
_ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false)
|
|
|
|
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort)
|
|
|
|
XCTAssertTrue(workspace.isRemoteWorkspace)
|
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
|
|
|
|
_ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false))
|
|
|
|
XCTAssertTrue(workspace.isRemoteWorkspace)
|
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
|
|
}
|
|
}
|
|
|
|
final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase {
|
|
func testProxyBrokerTransportKeyIgnoresControlPath() {
|
|
let first = WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: 22,
|
|
identityFile: "~/.ssh/id_ed25519",
|
|
sshOptions: [
|
|
"Compression=yes",
|
|
"ControlMaster=auto",
|
|
"ControlPath=/tmp/cmux-ssh-501-64000-%C",
|
|
],
|
|
localProxyPort: 9000,
|
|
relayPort: 64000,
|
|
relayID: "relay-a",
|
|
relayToken: "token-a",
|
|
localSocketPath: "/tmp/cmux-a.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
)
|
|
let second = WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: 22,
|
|
identityFile: "~/.ssh/id_ed25519",
|
|
sshOptions: [
|
|
"Compression=yes",
|
|
"ControlMaster=auto",
|
|
"ControlPath=/tmp/cmux-ssh-501-64001-%C",
|
|
],
|
|
localProxyPort: 9000,
|
|
relayPort: 64001,
|
|
relayID: "relay-b",
|
|
relayToken: "token-b",
|
|
localSocketPath: "/tmp/cmux-b.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
)
|
|
|
|
XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey)
|
|
}
|
|
}
|
|
|
|
final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase {
|
|
func testSupportsMultiplePendingCallsResolvedOutOfOrder() {
|
|
let registry = WorkspaceRemoteDaemonPendingCallRegistry()
|
|
let first = registry.register()
|
|
let second = registry.register()
|
|
|
|
XCTAssertTrue(registry.resolve(id: second.id, payload: [
|
|
"ok": true,
|
|
"result": ["stream_id": "second"],
|
|
]))
|
|
|
|
switch registry.wait(for: second, timeout: 0.1) {
|
|
case .response(let response):
|
|
XCTAssertEqual(response["ok"] as? Bool, true)
|
|
XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second")
|
|
default:
|
|
XCTFail("second pending call should complete independently")
|
|
}
|
|
|
|
XCTAssertTrue(registry.resolve(id: first.id, payload: [
|
|
"ok": true,
|
|
"result": ["stream_id": "first"],
|
|
]))
|
|
|
|
switch registry.wait(for: first, timeout: 0.1) {
|
|
case .response(let response):
|
|
XCTAssertEqual(response["ok"] as? Bool, true)
|
|
XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first")
|
|
default:
|
|
XCTFail("first pending call should remain pending until its own response arrives")
|
|
}
|
|
}
|
|
|
|
func testFailAllSignalsEveryPendingCall() {
|
|
let registry = WorkspaceRemoteDaemonPendingCallRegistry()
|
|
let first = registry.register()
|
|
let second = registry.register()
|
|
|
|
registry.failAll("daemon transport stopped")
|
|
|
|
switch registry.wait(for: first, timeout: 0.1) {
|
|
case .failure(let message):
|
|
XCTAssertEqual(message, "daemon transport stopped")
|
|
default:
|
|
XCTFail("first pending call should receive shared failure")
|
|
}
|
|
|
|
switch registry.wait(for: second, timeout: 0.1) {
|
|
case .failure(let message):
|
|
XCTAssertEqual(message, "daemon transport stopped")
|
|
default:
|
|
XCTFail("second pending call should receive shared failure")
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WindowBackgroundSelectionGateTests: XCTestCase {
|
|
func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() {
|
|
let tabId = UUID()
|
|
let activeSelectedTabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: tabId,
|
|
activeSelectedTabId: activeSelectedTabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundRejectsWhenOwningSelectionDiffers() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenOwningManagerSelectionIsTemporarilyNil() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundFallsBackToActiveSelection() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: tabId
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldApplyWindowBackgroundAllowsWhenNoSelectionContext() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: UUID(),
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: false,
|
|
owningSelectedTabId: nil,
|
|
activeSelectedTabId: nil
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldApplyWindowBackground(
|
|
surfaceTabId: nil,
|
|
owningManagerExists: true,
|
|
owningSelectedTabId: UUID(),
|
|
activeSelectedTabId: UUID()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
final class NotificationBurstCoalescerTests: XCTestCase {
|
|
func testSignalsInSameBurstFlushOnce() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush once")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
for _ in 0..<8 {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 1)
|
|
}
|
|
|
|
func testLatestActionWinsWithinBurst() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "latest action flushed")
|
|
var value = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
value = 1
|
|
}
|
|
coalescer.signal {
|
|
value = 2
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(value, 2)
|
|
}
|
|
|
|
func testSignalsAcrossBurstsFlushMultipleTimes() {
|
|
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
|
let expectation = expectation(description: "flush twice")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var flushCount = 0
|
|
|
|
DispatchQueue.main.async {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
coalescer.signal {
|
|
flushCount += 1
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(flushCount, 2)
|
|
}
|
|
}
|
|
|
|
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
|
|
func testSignalCoalescesBurstToLatestBackground() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "coalesced notification")
|
|
expectation.expectedFulfillmentCount = 1
|
|
var postedUserInfos: [[AnyHashable: Any]] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
postedUserInfos.append(userInfo)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
|
|
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedUserInfos.count, 1)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
|
|
"#FDF6E3"
|
|
)
|
|
XCTAssertEqual(
|
|
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
|
|
0.75,
|
|
accuracy: 0.0001
|
|
)
|
|
XCTAssertEqual(
|
|
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
|
|
2
|
|
)
|
|
XCTAssertEqual(
|
|
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
|
|
"test.light"
|
|
)
|
|
}
|
|
|
|
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
|
|
guard let dark = NSColor(hex: "#272822"),
|
|
let light = NSColor(hex: "#FDF6E3") else {
|
|
XCTFail("Expected valid test colors")
|
|
return
|
|
}
|
|
|
|
let expectation = expectation(description: "two notifications")
|
|
expectation.expectedFulfillmentCount = 2
|
|
var postedHexes: [String] = []
|
|
|
|
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
|
delay: 0.01,
|
|
postNotification: { userInfo in
|
|
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
|
postedHexes.append(hex)
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
|
|
}
|
|
}
|
|
|
|
wait(for: [expectation], timeout: 1.0)
|
|
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
|
|
}
|
|
|
|
private func postedOpacity(from value: Any?) -> Double {
|
|
if let value = value as? Double {
|
|
return value
|
|
}
|
|
if let value = value as? NSNumber {
|
|
return value.doubleValue
|
|
}
|
|
XCTFail("Expected background opacity payload")
|
|
return -1
|
|
}
|
|
}
|
|
|
|
final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|
func testPopReturnsEntriesInLIFOOrder() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 20)
|
|
stack.push(makeSnapshot(index: 1))
|
|
stack.push(makeSnapshot(index: 2))
|
|
stack.push(makeSnapshot(index: 3))
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
func testPushDropsOldestEntriesWhenCapacityExceeded() {
|
|
var stack = RecentlyClosedBrowserStack(capacity: 3)
|
|
for index in 1...5 {
|
|
stack.push(makeSnapshot(index: index))
|
|
}
|
|
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
|
|
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
|
XCTAssertNil(stack.pop())
|
|
}
|
|
|
|
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
|
|
ClosedBrowserPanelRestoreSnapshot(
|
|
workspaceId: UUID(),
|
|
url: URL(string: "https://example.com/\(index)"),
|
|
profileID: nil,
|
|
originalPaneId: UUID(),
|
|
originalTabIndex: index,
|
|
fallbackSplitOrientation: .horizontal,
|
|
fallbackSplitInsertFirst: false,
|
|
fallbackAnchorPaneId: UUID()
|
|
)
|
|
}
|
|
}
|
|
|
|
final class SocketControlSettingsTests: XCTestCase {
|
|
func testMigrateModeSupportsExpandedSocketModes() {
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
|
|
|
|
// Legacy aliases
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
|
|
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
|
|
}
|
|
|
|
func testSocketModePermissions() {
|
|
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
|
|
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
|
|
}
|
|
|
|
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
|
|
XCTAssertNil(
|
|
SocketControlSettings.envOverrideMode(
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.effectiveMode(
|
|
userMode: .password,
|
|
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
|
),
|
|
.password
|
|
)
|
|
}
|
|
|
|
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
)
|
|
|
|
XCTAssertEqual(path, SocketControlSettings.stableDefaultSocketPath)
|
|
}
|
|
|
|
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.nightly",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
|
|
}
|
|
|
|
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
|
|
}
|
|
|
|
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
|
|
isDebugBuild: false
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
|
|
}
|
|
|
|
func testStableReleaseCanOptInToSocketOverride() {
|
|
let path = SocketControlSettings.socketPath(
|
|
environment: [
|
|
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
|
|
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
|
|
],
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
)
|
|
|
|
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
|
|
}
|
|
|
|
func testDefaultSocketPathByChannel() {
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
),
|
|
SocketControlSettings.stableDefaultSocketPath
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app.nightly",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
),
|
|
"/tmp/cmux-nightly.sock"
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app.debug.tag",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
),
|
|
"/tmp/cmux-debug.sock"
|
|
)
|
|
XCTAssertEqual(
|
|
SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app.staging.tag",
|
|
isDebugBuild: false,
|
|
probeStableDefaultPathEntry: { _ in .missing }
|
|
),
|
|
"/tmp/cmux-staging.sock"
|
|
)
|
|
}
|
|
|
|
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathOwnedByDifferentUser() {
|
|
let path = SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
currentUserID: 501,
|
|
probeStableDefaultPathEntry: { _ in .socket(ownerUserID: 0) }
|
|
)
|
|
|
|
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
|
|
}
|
|
|
|
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathIsBlockedByNonSocketEntry() {
|
|
let path = SocketControlSettings.defaultSocketPath(
|
|
bundleIdentifier: "com.cmuxterm.app",
|
|
isDebugBuild: false,
|
|
currentUserID: 501,
|
|
probeStableDefaultPathEntry: { _ in .other(ownerUserID: 501) }
|
|
)
|
|
|
|
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
|
|
}
|
|
|
|
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
|
XCTAssertTrue(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testUntaggedDebugBundleAllowedWithLaunchTag() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["CMUX_TAG": "tests-v1"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testReleaseBuildIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: [:],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
|
// XCUITest launches the app as a separate process without XCTest env vars.
|
|
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
|
XCTAssertFalse(
|
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
|
environment: ["CMUX_UI_TEST_MODE": "1"],
|
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
|
isDebugBuild: true
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
final class UITestLaunchManifestTests: XCTestCase {
|
|
func testManifestPathReadsArgumentValue() {
|
|
XCTAssertEqual(
|
|
UITestLaunchManifest.manifestPath(
|
|
from: ["cmux", "-cmuxUITestLaunchManifest", "/tmp/cmux-ui-test-launch.json"]
|
|
),
|
|
"/tmp/cmux-ui-test-launch.json"
|
|
)
|
|
}
|
|
|
|
func testManifestPathReturnsNilWithoutValue() {
|
|
XCTAssertNil(
|
|
UITestLaunchManifest.manifestPath(
|
|
from: ["cmux", "-cmuxUITestLaunchManifest"]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testApplyIfPresentDecodesEnvironmentPayload() {
|
|
let payload = """
|
|
{"environment":{"CMUX_TAG":"ui-tests-display","CMUX_SOCKET_PATH":"/tmp/cmux-ui-tests.sock"}}
|
|
""".data(using: .utf8)!
|
|
var applied: [String: String] = [:]
|
|
|
|
UITestLaunchManifest.applyIfPresent(
|
|
arguments: ["cmux", UITestLaunchManifest.argumentName, "/tmp/cmux-ui-test-launch.json"],
|
|
loadData: { _ in payload },
|
|
applyEnvironment: { key, value in
|
|
applied[key] = value
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(applied["CMUX_TAG"], "ui-tests-display")
|
|
XCTAssertEqual(applied["CMUX_SOCKET_PATH"], "/tmp/cmux-ui-tests.sock")
|
|
}
|
|
}
|
|
|
|
final class PostHogAnalyticsPropertiesTests: XCTestCase {
|
|
func testDailyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testSuperPropertiesIncludePlatformVersionAndBuild() {
|
|
let properties = PostHogAnalytics.superProperties(
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyActivePropertiesIncludeVersionAndBuild() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "didBecomeActive",
|
|
infoDictionary: [
|
|
"CFBundleShortVersionString": "0.31.0",
|
|
"CFBundleVersion": "230",
|
|
]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
|
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
|
XCTAssertEqual(properties["app_build"] as? String, "230")
|
|
}
|
|
|
|
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let properties = PostHogAnalytics.hourlyActiveProperties(
|
|
hourUTC: "2026-02-21T14",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
|
|
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
|
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(properties["app_version"])
|
|
XCTAssertNil(properties["app_build"])
|
|
}
|
|
|
|
func testPropertiesOmitVersionFieldsWhenUnavailable() {
|
|
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
|
|
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
|
|
XCTAssertNil(superProperties["app_version"])
|
|
XCTAssertNil(superProperties["app_build"])
|
|
|
|
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
|
|
dayUTC: "2026-02-21",
|
|
reason: "activeTimer",
|
|
infoDictionary: [:]
|
|
)
|
|
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
|
|
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
|
|
XCTAssertNil(dailyProperties["app_version"])
|
|
XCTAssertNil(dailyProperties["app_build"])
|
|
}
|
|
|
|
func testFlushPolicyIncludesDailyAndHourlyActiveEvents() {
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_daily_active"))
|
|
XCTAssertTrue(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_hourly_active"))
|
|
XCTAssertFalse(PostHogAnalytics.shouldFlushAfterCapture(event: "cmux_other_event"))
|
|
}
|
|
}
|
|
|
|
final class GhosttyMouseFocusTests: XCTestCase {
|
|
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
|
|
XCTAssertTrue(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: false,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderDuringMouseDrag() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 1,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: false,
|
|
hiddenInHierarchy: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: true,
|
|
pressedMouseButtons: 0,
|
|
appIsActive: true,
|
|
windowIsKey: true,
|
|
alreadyFirstResponder: false,
|
|
visibleInUI: true,
|
|
hasUsableGeometry: true,
|
|
hiddenInHierarchy: true
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - CJK Font Fallback
|
|
|
|
private func withTempConfig(
|
|
_ contents: String,
|
|
body: (String) -> Void
|
|
) throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let file = dir.appendingPathComponent("config")
|
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
|
body(file.path)
|
|
}
|
|
|
|
// MARK: cjkFontMappings
|
|
|
|
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
|
|
let fonts = Set(mappings.map(\.1))
|
|
let ranges = mappings.map(\.0)
|
|
|
|
XCTAssertTrue(fonts.contains("Hiragino Sans"))
|
|
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
|
|
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
|
|
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
|
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsNilForKoreanOnly() {
|
|
// Korean is not auto-mapped — Ghostty's native CTFontCreateForString
|
|
// fallback selects a better-matching font for Hangul.
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"]))
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsPingFangForChinese() {
|
|
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
|
|
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
|
|
|
|
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
|
|
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
|
|
|
|
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
|
|
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
|
|
}
|
|
|
|
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
|
|
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
|
|
}
|
|
|
|
func testCJKFontMappingsMultiLanguageSkipsKorean() {
|
|
// When both ja and ko are preferred, only Japanese mappings are generated.
|
|
// Korean is left to Ghostty's native CTFontCreateForString fallback.
|
|
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
|
|
|
|
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
|
|
|
|
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
|
|
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
|
|
XCTAssertFalse(mappings.contains { $0.1 == "Apple SD Gothic Neo" }, "No Korean font mapping")
|
|
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
|
|
}
|
|
|
|
// MARK: userConfigContainsCJKCodepointMap
|
|
|
|
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
|
|
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
|
|
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
|
}
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
|
|
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
|
|
XCTAssertFalse(
|
|
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
|
|
)
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "font-family = Menlo\nconfig-file = \(included.path)\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "config-file = fonts.conf\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let included = dir.appendingPathComponent("fonts.conf")
|
|
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
|
.write(to: included, atomically: true, encoding: .utf8)
|
|
|
|
let main = dir.appendingPathComponent("config")
|
|
try "config-file = \(included.path)?\n"
|
|
.write(to: main, atomically: true, encoding: .utf8)
|
|
|
|
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
|
}
|
|
|
|
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let fileA = dir.appendingPathComponent("a.conf")
|
|
let fileB = dir.appendingPathComponent("b.conf")
|
|
try "config-file = \(fileB.path)\n"
|
|
.write(to: fileA, atomically: true, encoding: .utf8)
|
|
try "config-file = \(fileA.path)\n"
|
|
.write(to: fileB, atomically: true, encoding: .utf8)
|
|
|
|
// Should not hang; should return false since neither file has font-codepoint-map
|
|
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
|
|
}
|
|
}
|
|
|
|
final class SidebarBackgroundConfigTests: XCTestCase {
|
|
|
|
func testParseSidebarBackgroundSingleHex() {
|
|
var config = GhosttyConfig()
|
|
config.parse("sidebar-background = #336699")
|
|
XCTAssertEqual(config.rawSidebarBackground, "#336699")
|
|
}
|
|
|
|
func testParseSidebarBackgroundDualMode() {
|
|
var config = GhosttyConfig()
|
|
config.parse("sidebar-background = light:#fbf3db,dark:#103c48")
|
|
XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48")
|
|
}
|
|
|
|
func testParseSidebarTintOpacity() {
|
|
var config = GhosttyConfig()
|
|
config.parse("sidebar-tint-opacity = 0.4")
|
|
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001)
|
|
}
|
|
|
|
func testParseSidebarTintOpacityClampedAboveOne() {
|
|
var config = GhosttyConfig()
|
|
config.parse("sidebar-tint-opacity = 1.5")
|
|
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001)
|
|
}
|
|
|
|
func testParseSidebarTintOpacityClampedBelowZero() {
|
|
var config = GhosttyConfig()
|
|
config.parse("sidebar-tint-opacity = -0.3")
|
|
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001)
|
|
}
|
|
|
|
func testResolveSidebarBackgroundSingleHex() {
|
|
var config = GhosttyConfig()
|
|
config.rawSidebarBackground = "#336699"
|
|
config.resolveSidebarBackground(preferredColorScheme: .light)
|
|
|
|
XCTAssertNotNil(config.sidebarBackground)
|
|
XCTAssertNil(config.sidebarBackgroundLight)
|
|
XCTAssertNil(config.sidebarBackgroundDark)
|
|
}
|
|
|
|
func testResolveSidebarBackgroundDualModeSetsLightAndDark() {
|
|
var config = GhosttyConfig()
|
|
config.rawSidebarBackground = "light:#fbf3db,dark:#103c48"
|
|
config.resolveSidebarBackground(preferredColorScheme: .light)
|
|
|
|
XCTAssertNotNil(config.sidebarBackgroundLight)
|
|
XCTAssertNotNil(config.sidebarBackgroundDark)
|
|
XCTAssertNotNil(config.sidebarBackground)
|
|
}
|
|
|
|
func testResolveSidebarBackgroundNilWhenNoRaw() {
|
|
var config = GhosttyConfig()
|
|
config.resolveSidebarBackground(preferredColorScheme: .dark)
|
|
|
|
XCTAssertNil(config.sidebarBackground)
|
|
XCTAssertNil(config.sidebarBackgroundLight)
|
|
XCTAssertNil(config.sidebarBackgroundDark)
|
|
}
|
|
|
|
func testApplyToUserDefaultsSkipsWritesWhenNoConfig() {
|
|
let defaults = UserDefaults.standard
|
|
let testKey = "sidebarTintHex"
|
|
let original = defaults.string(forKey: testKey)
|
|
defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) }
|
|
|
|
defaults.set("#AAAAAA", forKey: testKey)
|
|
|
|
var config = GhosttyConfig()
|
|
config.applySidebarAppearanceToUserDefaults()
|
|
|
|
XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA",
|
|
"Should not overwrite UserDefaults when rawSidebarBackground is nil")
|
|
}
|
|
|
|
func testApplyToUserDefaultsWritesHexWhenConfigSet() {
|
|
let defaults = UserDefaults.standard
|
|
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
|
|
let originals = keys.map { defaults.object(forKey: $0) }
|
|
defer {
|
|
for (key, original) in zip(keys, originals) {
|
|
restoreDefaultsValue(original, key: key, defaults: defaults)
|
|
}
|
|
}
|
|
|
|
var config = GhosttyConfig()
|
|
config.rawSidebarBackground = "#336699"
|
|
config.resolveSidebarBackground(preferredColorScheme: .light)
|
|
config.applySidebarAppearanceToUserDefaults()
|
|
|
|
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699")
|
|
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"))
|
|
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"))
|
|
}
|
|
|
|
func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() {
|
|
let defaults = UserDefaults.standard
|
|
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
|
|
let originals = keys.map { defaults.object(forKey: $0) }
|
|
defer {
|
|
for (key, original) in zip(keys, originals) {
|
|
restoreDefaultsValue(original, key: key, defaults: defaults)
|
|
}
|
|
}
|
|
|
|
defaults.set("#AAAAAA", forKey: "sidebarTintHexLight")
|
|
defaults.set("#BBBBBB", forKey: "sidebarTintHexDark")
|
|
|
|
var config = GhosttyConfig()
|
|
config.rawSidebarBackground = "#222222"
|
|
config.resolveSidebarBackground(preferredColorScheme: .light)
|
|
config.applySidebarAppearanceToUserDefaults()
|
|
|
|
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222")
|
|
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"),
|
|
"Stale light key should be cleared")
|
|
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"),
|
|
"Stale dark key should be cleared")
|
|
}
|
|
|
|
func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() {
|
|
let defaults = UserDefaults.standard
|
|
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"]
|
|
let originals = keys.map { defaults.object(forKey: $0) }
|
|
defer {
|
|
for (key, original) in zip(keys, originals) {
|
|
restoreDefaultsValue(original, key: key, defaults: defaults)
|
|
}
|
|
}
|
|
|
|
defaults.set(0.18, forKey: "sidebarTintOpacity")
|
|
|
|
var config = GhosttyConfig()
|
|
config.rawSidebarBackground = "#336699"
|
|
config.resolveSidebarBackground(preferredColorScheme: .light)
|
|
config.applySidebarAppearanceToUserDefaults()
|
|
|
|
XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001,
|
|
"Should not overwrite opacity when config doesn't set sidebar-tint-opacity")
|
|
}
|
|
|
|
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
|
|
if let value = value {
|
|
defaults.set(value, forKey: key)
|
|
} else {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ZshShellIntegrationHandoffTests: XCTestCase {
|
|
func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws {
|
|
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true)
|
|
|
|
XCTAssertTrue(output.contains("PRECMD=1"), output)
|
|
XCTAssertTrue(output.contains("PREEXEC=1"), output)
|
|
XCTAssertTrue(output.contains("PRECMDS=_ghostty_precmd"), output)
|
|
}
|
|
|
|
func testGhosttyPromptHooksDoNotLoadWithoutCmuxHandoffFlag() throws {
|
|
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: false)
|
|
|
|
XCTAssertTrue(output.contains("PRECMD=0"), output)
|
|
XCTAssertTrue(output.contains("PREEXEC=0"), output)
|
|
}
|
|
|
|
func testGhosttySemanticPatchRetriesAfterDeferredInitCreatesLiveHooks() throws {
|
|
let output = try runInteractiveZsh(
|
|
cmuxLoadGhosttyIntegration: true,
|
|
cmuxLoadShellIntegration: true,
|
|
command: """
|
|
_cmux_patch_ghostty_semantic_redraw
|
|
(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1
|
|
_cmux_patch_ghostty_semantic_redraw
|
|
print -r -- "PRECMD_BODY=${functions[_ghostty_precmd]}"
|
|
print -r -- "PREEXEC_BODY=${functions[_ghostty_preexec]}"
|
|
"""
|
|
)
|
|
|
|
XCTAssertTrue(output.contains("PRECMD_BODY="), output)
|
|
XCTAssertTrue(output.contains("PREEXEC_BODY="), output)
|
|
XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output)
|
|
}
|
|
|
|
func testShellIntegrationWinchGuardDoesNotPrintSpacerLineOnResize() throws {
|
|
let output = try runInteractiveZsh(
|
|
cmuxLoadGhosttyIntegration: false,
|
|
cmuxLoadShellIntegration: true,
|
|
command: """
|
|
print -r -- BEFORE
|
|
TRAPWINCH
|
|
print -r -- AFTER
|
|
"""
|
|
)
|
|
|
|
XCTAssertEqual(output, "BEFORE\nAFTER", output)
|
|
}
|
|
|
|
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
|
|
try runInteractiveZsh(
|
|
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
|
|
cmuxLoadShellIntegration: false,
|
|
command: "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " +
|
|
"print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " +
|
|
"PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\""
|
|
)
|
|
}
|
|
|
|
private func runInteractiveZsh(
|
|
cmuxLoadGhosttyIntegration: Bool,
|
|
cmuxLoadShellIntegration: Bool,
|
|
command: String
|
|
) throws -> String {
|
|
let fileManager = FileManager.default
|
|
let root = fileManager.temporaryDirectory
|
|
.appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)")
|
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: root) }
|
|
|
|
let userZdotdir = root.appendingPathComponent("zdotdir")
|
|
try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true)
|
|
try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
|
|
|
|
let repoRoot = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration")
|
|
let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src")
|
|
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
process.arguments = [
|
|
"-i",
|
|
"-c", command
|
|
]
|
|
process.environment = [
|
|
"HOME": root.path,
|
|
"TERM": "xterm-256color",
|
|
"SHELL": "/bin/zsh",
|
|
"USER": NSUserName(),
|
|
"ZDOTDIR": cmuxZdotdir.path,
|
|
"CMUX_ZSH_ZDOTDIR": userZdotdir.path,
|
|
"CMUX_SHELL_INTEGRATION": "0",
|
|
"GHOSTTY_RESOURCES_DIR": ghosttyResources.path,
|
|
]
|
|
if cmuxLoadGhosttyIntegration {
|
|
process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
|
}
|
|
if cmuxLoadShellIntegration {
|
|
process.environment?["CMUX_SHELL_INTEGRATION"] = "1"
|
|
process.environment?["CMUX_SHELL_INTEGRATION_DIR"] = cmuxZdotdir.path
|
|
process.environment?["CMUX_SOCKET_PATH"] = root.appendingPathComponent("cmux-test.sock").path
|
|
process.environment?["CMUX_TAB_ID"] = "tab-test"
|
|
process.environment?["CMUX_PANEL_ID"] = "panel-test"
|
|
}
|
|
|
|
let stdout = Pipe()
|
|
let stderr = Pipe()
|
|
process.standardOutput = stdout
|
|
process.standardError = stderr
|
|
|
|
try process.run()
|
|
let deadline = Date().addingTimeInterval(5)
|
|
while process.isRunning && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
if process.isRunning {
|
|
process.terminate()
|
|
process.waitUntilExit()
|
|
XCTFail("Timed out waiting for zsh to exit")
|
|
}
|
|
|
|
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
|
|
XCTAssertEqual(process.terminationStatus, 0, error)
|
|
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
final class BrowserInstallDetectorTests: XCTestCase {
|
|
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
|
|
let home = makeTemporaryHome()
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
try createFile(
|
|
at: home
|
|
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
|
|
contents: Data()
|
|
)
|
|
try createFile(
|
|
at: home
|
|
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
|
|
contents: Data()
|
|
)
|
|
|
|
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
|
homeDirectoryURL: home,
|
|
bundleLookup: { bundleIdentifier in
|
|
if bundleIdentifier == "com.google.Chrome" {
|
|
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
|
|
}
|
|
return nil
|
|
},
|
|
applicationSearchDirectories: []
|
|
)
|
|
|
|
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
|
|
XCTFail("Expected Chrome to be detected")
|
|
return
|
|
}
|
|
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
|
|
XCTFail("Expected Firefox to be detected from profile data")
|
|
return
|
|
}
|
|
|
|
XCTAssertNotNil(chrome.appURL)
|
|
XCTAssertEqual(firefox.profileURLs.count, 1)
|
|
XCTAssertNil(firefox.appURL)
|
|
}
|
|
|
|
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
|
|
let home = makeTemporaryHome()
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
|
homeDirectoryURL: home,
|
|
bundleLookup: { _ in nil },
|
|
applicationSearchDirectories: []
|
|
)
|
|
|
|
XCTAssertTrue(detected.isEmpty)
|
|
}
|
|
|
|
func testUngoogledChromiumRequiresAppSignal() throws {
|
|
let home = makeTemporaryHome()
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
try createFile(
|
|
at: home
|
|
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
|
|
contents: Data()
|
|
)
|
|
|
|
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
|
homeDirectoryURL: home,
|
|
bundleLookup: { _ in nil },
|
|
applicationSearchDirectories: []
|
|
)
|
|
|
|
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
|
|
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
|
|
}
|
|
|
|
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
|
|
let home = makeTemporaryHome()
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
|
|
try createFile(
|
|
at: heliumRoot.appendingPathComponent("Default/History"),
|
|
contents: Data()
|
|
)
|
|
try createFile(
|
|
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
|
|
contents: Data()
|
|
)
|
|
try createFile(
|
|
at: heliumRoot.appendingPathComponent("Local State"),
|
|
contents: Data(
|
|
"""
|
|
{
|
|
"profile": {
|
|
"info_cache": {
|
|
"Default": {
|
|
"name": "Personal"
|
|
},
|
|
"Profile 1": {
|
|
"name": "Work"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""".utf8
|
|
)
|
|
)
|
|
|
|
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
|
homeDirectoryURL: home,
|
|
bundleLookup: { _ in nil },
|
|
applicationSearchDirectories: []
|
|
)
|
|
|
|
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
|
|
XCTFail("Expected Helium to be detected")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(helium.family, .chromium)
|
|
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
|
|
XCTAssertEqual(
|
|
helium.profiles.map(\.rootURL.lastPathComponent),
|
|
["Default", "Profile 1"]
|
|
)
|
|
}
|
|
|
|
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
|
|
let home = makeTemporaryHome()
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
try createFile(
|
|
at: home.appendingPathComponent("Library/Safari/History.db"),
|
|
contents: Data()
|
|
)
|
|
try createFile(
|
|
at: home.appendingPathComponent(
|
|
"Library/Safari/Profiles/Work/History.db"
|
|
),
|
|
contents: Data()
|
|
)
|
|
try createFile(
|
|
at: home.appendingPathComponent(
|
|
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
|
|
),
|
|
contents: Data()
|
|
)
|
|
|
|
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
|
homeDirectoryURL: home,
|
|
bundleLookup: { _ in nil },
|
|
applicationSearchDirectories: []
|
|
)
|
|
|
|
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
|
|
XCTFail("Expected Safari to be detected")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"]))
|
|
XCTAssertEqual(
|
|
safari.profiles
|
|
.map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) }
|
|
.sorted(),
|
|
[
|
|
home.appendingPathComponent("Library/Safari", isDirectory: true)
|
|
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
|
|
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true)
|
|
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
|
|
home.appendingPathComponent(
|
|
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
|
|
isDirectory: true
|
|
).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
|
|
].sorted()
|
|
)
|
|
}
|
|
|
|
private func makeTemporaryHome() -> URL {
|
|
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
|
|
}
|
|
|
|
private func createFile(at url: URL, contents: Data) throws {
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
guard FileManager.default.createFile(atPath: url.path, contents: contents) else {
|
|
throw CocoaError(
|
|
.fileWriteUnknown,
|
|
userInfo: [NSFilePathErrorKey: url.path]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class BrowserImportScopeTests: XCTestCase {
|
|
func testFromSelectionCookiesOnly() {
|
|
let scope = BrowserImportScope.fromSelection(
|
|
includeCookies: true,
|
|
includeHistory: false,
|
|
includeAdditionalData: false
|
|
)
|
|
XCTAssertEqual(scope, .cookiesOnly)
|
|
}
|
|
|
|
func testFromSelectionHistoryOnly() {
|
|
let scope = BrowserImportScope.fromSelection(
|
|
includeCookies: false,
|
|
includeHistory: true,
|
|
includeAdditionalData: false
|
|
)
|
|
XCTAssertEqual(scope, .historyOnly)
|
|
}
|
|
|
|
func testFromSelectionCookiesAndHistory() {
|
|
let scope = BrowserImportScope.fromSelection(
|
|
includeCookies: true,
|
|
includeHistory: true,
|
|
includeAdditionalData: false
|
|
)
|
|
XCTAssertEqual(scope, .cookiesAndHistory)
|
|
}
|
|
|
|
func testFromSelectionEverything() {
|
|
let scope = BrowserImportScope.fromSelection(
|
|
includeCookies: false,
|
|
includeHistory: false,
|
|
includeAdditionalData: true
|
|
)
|
|
XCTAssertEqual(scope, .everything)
|
|
}
|
|
|
|
func testFromSelectionRejectsEmptySelection() {
|
|
let scope = BrowserImportScope.fromSelection(
|
|
includeCookies: false,
|
|
includeHistory: false,
|
|
includeAdditionalData: false
|
|
)
|
|
XCTAssertNil(scope)
|
|
}
|
|
}
|