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