cmux/cmuxTests/SessionPersistenceTests.swift

382 lines
15 KiB
Swift

import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class SessionPersistenceTests: XCTestCase {
func testSaveAndLoadRoundTripWithCustomSnapshotPath() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
XCTAssertNotNil(loaded)
XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion)
XCTAssertEqual(loaded?.windows.count, 1)
XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs)
}
func testLoadRejectsSchemaVersionMismatch() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL))
XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL))
}
func testDefaultSnapshotPathSanitizesBundleIdentifier() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let path = SessionPersistenceStore.defaultSnapshotFileURL(
bundleIdentifier: "com.example/unsafe id",
appSupportDirectory: tempDir
)
XCTAssertNotNil(path)
XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true)
}
func testRestorePolicySkipsWhenLaunchHasExplicitArguments() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"],
environment: [:]
)
XCTAssertFalse(shouldRestore)
}
func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"],
environment: [:]
)
XCTAssertTrue(shouldRestore)
}
func testRestorePolicySkipsWhenRunningUnderXCTest() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"],
environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"]
)
XCTAssertFalse(shouldRestore)
}
func testSidebarWidthSanitizationClampsToPolicyRange() {
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(-20),
SessionPersistencePolicy.minimumSidebarWidth,
accuracy: 0.001
)
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(10_000),
SessionPersistencePolicy.maximumSidebarWidth,
accuracy: 0.001
)
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(nil),
SessionPersistencePolicy.defaultSidebarWidth,
accuracy: 0.001
)
}
func testScrollbackReplayEnvironmentWritesReplayFile() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: "line one\nline two\n",
tempDirectory: tempDir
)
let path = environment[SessionScrollbackReplayStore.environmentKey]
XCTAssertNotNil(path)
XCTAssertTrue(path?.hasPrefix(tempDir.path) == true)
guard let path else { return }
let contents = try? String(contentsOfFile: path, encoding: .utf8)
XCTAssertEqual(contents, "line one\nline two\n")
}
func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: " \n\t ",
tempDirectory: tempDir
)
XCTAssertTrue(environment.isEmpty)
}
func testScrollbackReplayEnvironmentPreservesANSIColorSequences() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let red = "\u{001B}[31m"
let reset = "\u{001B}[0m"
let source = "\(red)RED\(reset)\n"
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: source,
tempDirectory: tempDir
)
guard let path = environment[SessionScrollbackReplayStore.environmentKey] else {
XCTFail("Expected replay file path")
return
}
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
XCTFail("Expected replay file contents")
return
}
XCTAssertTrue(contents.contains("\(red)RED\(reset)"))
XCTAssertTrue(contents.hasPrefix(reset))
XCTAssertTrue(contents.hasSuffix(reset))
}
func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() {
let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
let source = "\u{001B}[31m"
+ String(repeating: "X", count: maxChars - 7)
+ "\u{001B}[0m"
guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else {
XCTFail("Expected truncated scrollback")
return
}
XCTAssertFalse(truncated.hasPrefix("31m"))
XCTAssertFalse(truncated.hasPrefix("[31m"))
XCTAssertFalse(truncated.hasPrefix("m"))
}
func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() {
XCTAssertEqual(
TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"),
"/tmp/cmux-screen.txt"
)
XCTAssertEqual(
TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "),
"/tmp/cmux-screen.txt"
)
}
func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() {
XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt"))
XCTAssertNil(TerminalController.normalizedExportedScreenPath(" "))
XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil))
}
func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() {
let tempRoot = URL(fileURLWithPath: "/tmp")
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
let tempFile = tempRoot
.appendingPathComponent(UUID().uuidString, isDirectory: true)
.appendingPathComponent("screen.txt", isDirectory: false)
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
XCTAssertTrue(
TerminalController.shouldRemoveExportedScreenDirectory(
fileURL: tempFile,
temporaryDirectory: tempRoot
)
)
XCTAssertFalse(
TerminalController.shouldRemoveExportedScreenDirectory(
fileURL: outsideFile,
temporaryDirectory: tempRoot
)
)
}
func testWindowUnregisterSnapshotPersistencePolicy() {
XCTAssertTrue(
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false)
)
XCTAssertFalse(
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)
)
}
func testResolvedWindowFramePrefersSavedDisplayIdentity() {
let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800),
visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800)
)
// Display 1 and 2 swapped horizontal positions between snapshot and restore.
let display1 = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800)
)
let display2 = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [display1, display2],
fallbackDisplay: display1
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(display2.visibleFrame.intersects(restored))
XCTAssertFalse(display1.visibleFrame.intersects(restored))
XCTAssertEqual(restored.width, 600, accuracy: 0.001)
XCTAssertEqual(restored.height, 400, accuracy: 0.001)
XCTAssertEqual(restored.minX, 200, accuracy: 0.001)
XCTAssertEqual(restored.minY, 100, accuracy: 0.001)
}
func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() {
let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: nil,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 120, accuracy: 0.001)
XCTAssertEqual(restored.minY, 80, accuracy: 0.001)
XCTAssertEqual(restored.width, 500, accuracy: 0.001)
XCTAssertEqual(restored.height, 350, accuracy: 0.001)
}
func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() {
let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: nil,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(display.visibleFrame.contains(restored))
XCTAssertEqual(restored.minX, 50, accuracy: 0.001)
XCTAssertEqual(restored.minY, 50, accuracy: 0.001)
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
}
func testResolvedSnapshotTerminalScrollbackPrefersCaptured() {
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: "captured-value",
fallbackScrollback: "fallback-value"
)
XCTAssertEqual(resolved, "captured-value")
}
func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() {
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: nil,
fallbackScrollback: "fallback-value"
)
XCTAssertEqual(resolved, "fallback-value")
}
func testResolvedSnapshotTerminalScrollbackTruncatesFallback() {
let oversizedFallback = String(
repeating: "x",
count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37
)
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: nil,
fallbackScrollback: oversizedFallback
)
XCTAssertEqual(
resolved?.count,
SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
)
}
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
let workspace = SessionWorkspaceSnapshot(
processTitle: "Terminal",
customTitle: "Restored",
isPinned: true,
currentDirectory: "/tmp",
focusedPanelId: nil,
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
panels: [],
statusEntries: [],
logEntries: [],
progress: nil,
gitBranch: nil
)
let tabManager = SessionTabManagerSnapshot(
selectedWorkspaceIndex: 0,
workspaces: [workspace]
)
let window = SessionWindowSnapshot(
frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700),
display: SessionDisplaySnapshot(
displayID: 42,
frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200),
visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175)
),
tabManager: tabManager,
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240)
)
return AppSessionSnapshot(
version: version,
createdAt: Date().timeIntervalSince1970,
windows: [window]
)
}
}