* test: add resize_split regression coverage * fix: implement Ghostty resize_split behavior * test: cover more resize_split cases * test: deduplicate split snapshot helper * Resolve merge conflict: keep both splitNodes and waitForCondition helpers --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
1950 lines
77 KiB
Swift
1950 lines
77 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
import ObjectiveC.runtime
|
|
import Bonsplit
|
|
import UserNotifications
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut"
|
|
|
|
func drainMainQueue() {
|
|
let expectation = XCTestExpectation(description: "drain main queue")
|
|
DispatchQueue.main.async {
|
|
expectation.fulfill()
|
|
}
|
|
XCTWaiter().wait(for: [expectation], timeout: 1.0)
|
|
}
|
|
|
|
@discardableResult
|
|
private func waitForCondition(
|
|
timeout: TimeInterval = 3.0,
|
|
pollInterval: TimeInterval = 0.05,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line,
|
|
_ condition: @escaping () -> Bool
|
|
) -> Bool {
|
|
if condition() {
|
|
return true
|
|
}
|
|
|
|
let expectation = XCTestExpectation(description: "wait for condition")
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
|
|
func poll() {
|
|
if condition() {
|
|
expectation.fulfill()
|
|
return
|
|
}
|
|
guard Date() < deadline else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) {
|
|
poll()
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
poll()
|
|
}
|
|
|
|
let result = XCTWaiter().wait(for: [expectation], timeout: timeout + pollInterval + 0.1)
|
|
if result != .completed {
|
|
XCTFail("Timed out waiting for condition", file: file, line: line)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private struct ProcessRunResult {
|
|
let status: Int32
|
|
let stdout: String
|
|
let stderr: String
|
|
}
|
|
|
|
private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] {
|
|
switch node {
|
|
case .pane:
|
|
return []
|
|
case .split(let split):
|
|
return [split] + splitNodes(in: split.first) + splitNodes(in: split.second)
|
|
}
|
|
}
|
|
|
|
private func runProcess(
|
|
executablePath: String,
|
|
arguments: [String],
|
|
environment: [String: String]? = nil,
|
|
currentDirectoryURL: URL? = nil
|
|
) throws -> ProcessRunResult {
|
|
let process = Process()
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: executablePath)
|
|
process.arguments = arguments
|
|
process.environment = environment
|
|
process.currentDirectoryURL = currentDirectoryURL
|
|
process.standardInput = FileHandle.nullDevice
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
return ProcessRunResult(
|
|
status: process.terminationStatus,
|
|
stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "",
|
|
stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
)
|
|
}
|
|
|
|
private func runGit(
|
|
_ arguments: [String],
|
|
in directoryURL: URL,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) throws -> String {
|
|
let result = try runProcess(
|
|
executablePath: "/usr/bin/env",
|
|
arguments: ["git"] + arguments,
|
|
currentDirectoryURL: directoryURL
|
|
)
|
|
XCTAssertEqual(
|
|
result.status,
|
|
0,
|
|
"git \(arguments.joined(separator: " ")) failed: \(result.stderr)",
|
|
file: file,
|
|
line: line
|
|
)
|
|
return result.stdout
|
|
}
|
|
|
|
@MainActor
|
|
final class TabManagerChildExitCloseTests: XCTestCase {
|
|
func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
|
|
let manager = TabManager()
|
|
let first = manager.tabs[0]
|
|
let second = manager.addWorkspace()
|
|
let third = manager.addWorkspace()
|
|
|
|
manager.selectWorkspace(second)
|
|
XCTAssertEqual(manager.selectedTabId, second.id)
|
|
|
|
guard let secondPanelId = second.focusedPanelId else {
|
|
XCTFail("Expected focused panel in selected workspace")
|
|
return
|
|
}
|
|
|
|
manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id])
|
|
XCTAssertEqual(
|
|
manager.selectedTabId,
|
|
third.id,
|
|
"Expected selection to stay at the same index after deleting the selected workspace"
|
|
)
|
|
}
|
|
|
|
func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() {
|
|
let manager = TabManager()
|
|
let first = manager.tabs[0]
|
|
let second = manager.addWorkspace()
|
|
|
|
manager.selectWorkspace(second)
|
|
XCTAssertEqual(manager.selectedTabId, second.id)
|
|
|
|
guard let secondPanelId = second.focusedPanelId else {
|
|
XCTFail("Expected focused panel in selected workspace")
|
|
return
|
|
}
|
|
|
|
manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [first.id])
|
|
XCTAssertEqual(
|
|
manager.selectedTabId,
|
|
first.id,
|
|
"Expected previous workspace to be selected after closing the last-index workspace"
|
|
)
|
|
}
|
|
|
|
func testChildExitOnLastRemotePanelKeepsWorkspaceAndDemotesToLocal() throws {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let remotePanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
workspace.configureRemoteConnection(
|
|
WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: nil,
|
|
identityFile: nil,
|
|
sshOptions: [],
|
|
localProxyPort: nil,
|
|
relayPort: 64015,
|
|
relayID: String(repeating: "a", count: 16),
|
|
relayToken: String(repeating: "b", count: 64),
|
|
localSocketPath: "/tmp/cmux-debug-test.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
),
|
|
autoConnect: false
|
|
)
|
|
|
|
XCTAssertTrue(workspace.isRemoteWorkspace)
|
|
XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId))
|
|
|
|
manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId)
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(manager.selectedTabId, workspace.id)
|
|
XCTAssertEqual(manager.tabs.first?.id, workspace.id)
|
|
XCTAssertFalse(workspace.isRemoteWorkspace)
|
|
XCTAssertNil(workspace.panels[remotePanelId])
|
|
XCTAssertEqual(workspace.panels.count, 1)
|
|
XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId)
|
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
|
|
}
|
|
|
|
func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let remotePanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
workspace.configureRemoteConnection(
|
|
WorkspaceRemoteConfiguration(
|
|
destination: "cmux-macmini",
|
|
port: nil,
|
|
identityFile: nil,
|
|
sshOptions: [],
|
|
localProxyPort: nil,
|
|
relayPort: 64016,
|
|
relayID: String(repeating: "a", count: 16),
|
|
relayToken: String(repeating: "b", count: 64),
|
|
localSocketPath: "/tmp/cmux-debug-test.sock",
|
|
terminalStartupCommand: "ssh cmux-macmini"
|
|
),
|
|
autoConnect: false
|
|
)
|
|
|
|
workspace.markRemoteTerminalSessionEnded(surfaceId: remotePanelId, relayPort: 64016)
|
|
|
|
XCTAssertFalse(workspace.isRemoteWorkspace)
|
|
|
|
manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId)
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(manager.selectedTabId, workspace.id)
|
|
XCTAssertEqual(manager.tabs.first?.id, workspace.id)
|
|
XCTAssertFalse(workspace.isRemoteWorkspace)
|
|
XCTAssertNil(workspace.panels[remotePanelId])
|
|
XCTAssertEqual(workspace.panels.count, 1)
|
|
XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId)
|
|
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
|
|
}
|
|
|
|
func testChildExitOnNonLastPanelClosesOnlyPanel() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let initialPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split terminal panel to be created")
|
|
return
|
|
}
|
|
|
|
let panelCountBefore = workspace.panels.count
|
|
manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id)
|
|
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(manager.tabs.first?.id, workspace.id)
|
|
XCTAssertEqual(workspace.panels.count, panelCountBefore - 1)
|
|
XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain")
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerWorkspaceOwnershipTests: XCTestCase {
|
|
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
|
|
let manager = TabManager()
|
|
_ = manager.addWorkspace()
|
|
let initialTabIds = manager.tabs.map(\.id)
|
|
let initialSelectedTabId = manager.selectedTabId
|
|
|
|
let externalWorkspace = Workspace(title: "External workspace")
|
|
let externalPanelCountBefore = externalWorkspace.panels.count
|
|
let externalPanelTitlesBefore = externalWorkspace.panelTitles
|
|
|
|
manager.closeWorkspace(externalWorkspace)
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), initialTabIds)
|
|
XCTAssertEqual(manager.selectedTabId, initialSelectedTabId)
|
|
XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore)
|
|
XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class TabManagerPullRequestProbeTests: XCTestCase {
|
|
func testGitHubRepositorySlugsPrioritizeUpstreamThenOriginAndDeduplicate() {
|
|
let output = """
|
|
origin https://github.com/austinwang/cmux.git (fetch)
|
|
origin https://github.com/austinwang/cmux.git (push)
|
|
upstream git@github.com:manaflow-ai/cmux.git (fetch)
|
|
upstream git@github.com:manaflow-ai/cmux.git (push)
|
|
backup ssh://git@github.com/manaflow-ai/cmux.git (fetch)
|
|
mirror https://gitlab.com/manaflow-ai/cmux.git (fetch)
|
|
"""
|
|
|
|
XCTAssertEqual(
|
|
TabManager.githubRepositorySlugs(fromGitRemoteVOutput: output),
|
|
["manaflow-ai/cmux", "austinwang/cmux"]
|
|
)
|
|
}
|
|
|
|
func testPreferredPullRequestPrefersOpenOverMergedAndClosed() {
|
|
let candidates = [
|
|
TabManager.GitHubPullRequestProbeItem(
|
|
number: 1889,
|
|
state: "MERGED",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1889",
|
|
updatedAt: "2026-03-20T18:00:00Z"
|
|
),
|
|
TabManager.GitHubPullRequestProbeItem(
|
|
number: 1891,
|
|
state: "OPEN",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1891",
|
|
updatedAt: "2026-03-19T18:00:00Z"
|
|
),
|
|
TabManager.GitHubPullRequestProbeItem(
|
|
number: 1800,
|
|
state: "CLOSED",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1800",
|
|
updatedAt: "2026-03-21T18:00:00Z"
|
|
),
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
TabManager.preferredPullRequest(from: candidates),
|
|
candidates[1]
|
|
)
|
|
}
|
|
|
|
func testPreferredPullRequestPrefersMostRecentlyUpdatedWithinSameStatus() {
|
|
let olderOpen = TabManager.GitHubPullRequestProbeItem(
|
|
number: 1880,
|
|
state: "OPEN",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1880",
|
|
updatedAt: "2026-03-18T18:00:00Z"
|
|
)
|
|
let newerOpen = TabManager.GitHubPullRequestProbeItem(
|
|
number: 1890,
|
|
state: "OPEN",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1890",
|
|
updatedAt: "2026-03-20T18:00:00Z"
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
TabManager.preferredPullRequest(from: [olderOpen, newerOpen]),
|
|
newerOpen
|
|
)
|
|
}
|
|
|
|
func testPreferredPullRequestIgnoresMalformedCandidates() {
|
|
let valid = TabManager.GitHubPullRequestProbeItem(
|
|
number: 1888,
|
|
state: "OPEN",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/1888",
|
|
updatedAt: "2026-03-20T18:00:00Z"
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
TabManager.preferredPullRequest(from: [
|
|
TabManager.GitHubPullRequestProbeItem(
|
|
number: 9999,
|
|
state: "WHATEVER",
|
|
url: "https://github.com/manaflow-ai/cmux/pull/9999",
|
|
updatedAt: "2026-03-21T18:00:00Z"
|
|
),
|
|
TabManager.GitHubPullRequestProbeItem(
|
|
number: 10000,
|
|
state: "OPEN",
|
|
url: "not a url",
|
|
updatedAt: "2026-03-21T18:00:00Z"
|
|
),
|
|
valid,
|
|
]),
|
|
valid
|
|
)
|
|
}
|
|
|
|
func testShouldSkipWorkspacePullRequestLookupOnlyForExactMainAndMaster() {
|
|
XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "main"))
|
|
XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "master"))
|
|
XCTAssertTrue(TabManager.shouldSkipWorkspacePullRequestLookup(branch: " master \n"))
|
|
|
|
XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "Main"))
|
|
XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "mainline"))
|
|
XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "feature/main"))
|
|
XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "release/master-fix"))
|
|
}
|
|
|
|
func testTrackedWorkspaceGitMetadataPollCandidatesSkipMainAndMasterPanelsOnly() throws {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let mainPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
guard let masterPanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .horizontal),
|
|
let featurePanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .vertical),
|
|
let mainlinePanel = workspace.newTerminalSplit(from: mainPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split panels to be created")
|
|
return
|
|
}
|
|
|
|
let staleURL = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/371"))
|
|
workspace.updatePanelGitBranch(panelId: mainPanelId, branch: "main", isDirty: false)
|
|
workspace.updatePanelPullRequest(
|
|
panelId: mainPanelId,
|
|
number: 371,
|
|
label: "PR",
|
|
url: staleURL,
|
|
status: .open,
|
|
branch: "main"
|
|
)
|
|
workspace.updatePanelGitBranch(panelId: masterPanel.id, branch: "master", isDirty: false)
|
|
workspace.updatePanelGitBranch(panelId: featurePanel.id, branch: "feature/sidebar-pr", isDirty: false)
|
|
workspace.updatePanelGitBranch(panelId: mainlinePanel.id, branch: "mainline", isDirty: false)
|
|
|
|
XCTAssertEqual(
|
|
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id),
|
|
Set([featurePanel.id, mainlinePanel.id])
|
|
)
|
|
}
|
|
|
|
func testTrackedWorkspaceGitMetadataPollCandidatesSkipFocusedFallbackOnMainOnly() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
workspace.gitBranch = SidebarGitBranchState(branch: "main", isDirty: false)
|
|
XCTAssertTrue(
|
|
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id).isEmpty
|
|
)
|
|
|
|
workspace.gitBranch = SidebarGitBranchState(branch: "feature/sidebar-pr", isDirty: false)
|
|
XCTAssertEqual(
|
|
manager.trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: workspace.id),
|
|
Set([panelId])
|
|
)
|
|
}
|
|
|
|
func testResolvedCommandPathFallsBackOutsideAppPATH() throws {
|
|
let fileManager = FileManager.default
|
|
let tempDir = fileManager.temporaryDirectory.appendingPathComponent(
|
|
"cmux-command-path-\(UUID().uuidString)",
|
|
isDirectory: true
|
|
)
|
|
try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: tempDir) }
|
|
|
|
let executableName = "cmux-gh-test-\(UUID().uuidString)"
|
|
let executableURL = tempDir.appendingPathComponent(executableName)
|
|
try """
|
|
#!/bin/sh
|
|
exit 0
|
|
""".write(to: executableURL, atomically: true, encoding: .utf8)
|
|
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path)
|
|
|
|
XCTAssertEqual(
|
|
TabManager.resolvedCommandPathForTesting(
|
|
executable: executableName,
|
|
environment: ["PATH": "/usr/bin:/bin"],
|
|
fallbackDirectories: [tempDir.path]
|
|
),
|
|
executableURL.path
|
|
)
|
|
}
|
|
|
|
func testPeriodicWorkspaceGitMetadataRefreshClearsStalePullRequestAfterBranchReset() throws {
|
|
let fileManager = FileManager.default
|
|
let repoURL = fileManager.temporaryDirectory.appendingPathComponent("cmux-git-refresh-\(UUID().uuidString)")
|
|
try fileManager.createDirectory(at: repoURL, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: repoURL) }
|
|
|
|
try runGit(["init", "-b", "main"], in: repoURL)
|
|
try runGit(["config", "user.name", "cmux tests"], in: repoURL)
|
|
try runGit(["config", "user.email", "cmux@example.invalid"], in: repoURL)
|
|
try "seed\n".write(
|
|
to: repoURL.appendingPathComponent("README.md"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
try runGit(["add", "README.md"], in: repoURL)
|
|
try runGit(["commit", "-m", "Initial commit"], in: repoURL)
|
|
try runGit(["checkout", "-b", "feature/sidebar-pr"], in: repoURL)
|
|
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
workspace.updatePanelDirectory(panelId: panelId, directory: repoURL.path)
|
|
workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false)
|
|
workspace.updatePanelPullRequest(
|
|
panelId: panelId,
|
|
number: 1052,
|
|
label: "PR",
|
|
url: try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/1052")),
|
|
status: .open,
|
|
branch: "feature/sidebar-pr"
|
|
)
|
|
|
|
XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/sidebar-pr")
|
|
XCTAssertEqual(workspace.panelPullRequests[panelId]?.number, 1052)
|
|
XCTAssertEqual(workspace.sidebarPullRequestsInDisplayOrder().map(\.number), [1052])
|
|
|
|
try runGit(["checkout", "main"], in: repoURL)
|
|
|
|
manager.refreshTrackedWorkspaceGitMetadataForTesting()
|
|
|
|
XCTAssertTrue(
|
|
waitForCondition {
|
|
workspace.panelGitBranches[panelId]?.branch == "main"
|
|
&& workspace.panelPullRequests[panelId] == nil
|
|
}
|
|
)
|
|
XCTAssertEqual(workspace.gitBranch?.branch, "main")
|
|
XCTAssertNil(workspace.pullRequest)
|
|
XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
|
|
func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() {
|
|
let manager = TabManager()
|
|
let second = manager.addWorkspace()
|
|
let third = manager.addWorkspace()
|
|
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
|
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
|
manager.setCustomTitle(tabId: third.id, title: "Gamma")
|
|
|
|
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
|
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
|
prompts.append((title, message, acceptCmdD))
|
|
return true
|
|
}
|
|
|
|
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
|
|
|
|
let expectedMessage = String(
|
|
format: String(
|
|
localized: "dialog.closeWorkspaces.message",
|
|
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
|
),
|
|
locale: .current,
|
|
Int64(2),
|
|
"• Alpha\n• Beta"
|
|
)
|
|
XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close")
|
|
XCTAssertEqual(
|
|
prompts.first?.title,
|
|
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
|
)
|
|
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
|
XCTAssertEqual(prompts.first?.acceptCmdD, false)
|
|
XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"])
|
|
}
|
|
|
|
func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() {
|
|
let manager = TabManager()
|
|
let second = manager.addWorkspace()
|
|
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
|
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
|
|
|
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
|
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
|
prompts.append((title, message, acceptCmdD))
|
|
return false
|
|
}
|
|
|
|
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
|
|
|
|
let expectedMessage = String(
|
|
format: String(
|
|
localized: "dialog.closeWorkspacesWindow.message",
|
|
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
|
|
),
|
|
locale: .current,
|
|
Int64(2),
|
|
"• Alpha\n• Beta"
|
|
)
|
|
XCTAssertEqual(prompts.count, 1)
|
|
XCTAssertEqual(
|
|
prompts.first?.title,
|
|
String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
|
|
)
|
|
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
|
XCTAssertEqual(prompts.first?.acceptCmdD, true)
|
|
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"])
|
|
}
|
|
|
|
func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() {
|
|
let manager = TabManager()
|
|
let second = manager.addWorkspace()
|
|
let third = manager.addWorkspace()
|
|
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
|
|
manager.setCustomTitle(tabId: second.id, title: "Beta")
|
|
manager.setCustomTitle(tabId: third.id, title: "Gamma")
|
|
manager.selectWorkspace(second)
|
|
manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id])
|
|
|
|
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
|
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
|
prompts.append((title, message, acceptCmdD))
|
|
return false
|
|
}
|
|
|
|
manager.closeCurrentWorkspaceWithConfirmation()
|
|
|
|
let expectedMessage = String(
|
|
format: String(
|
|
localized: "dialog.closeWorkspaces.message",
|
|
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
|
),
|
|
locale: .current,
|
|
Int64(2),
|
|
"• Alpha\n• Beta"
|
|
)
|
|
XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog")
|
|
XCTAssertEqual(
|
|
prompts.first?.title,
|
|
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
|
)
|
|
XCTAssertEqual(prompts.first?.message, expectedMessage)
|
|
XCTAssertEqual(prompts.first?.acceptCmdD, false)
|
|
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"])
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerCloseCurrentPanelTests: XCTestCase {
|
|
func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId,
|
|
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
|
XCTFail("Expected selected workspace and focused terminal panel")
|
|
return
|
|
}
|
|
|
|
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
|
|
workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
|
|
|
|
var promptCount = 0
|
|
manager.confirmCloseHandler = { _, _, _ in
|
|
promptCount += 1
|
|
return false
|
|
}
|
|
|
|
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
|
|
XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
|
|
XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
|
|
}
|
|
|
|
func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId,
|
|
let terminalPanel = workspace.terminalPanel(for: panelId) else {
|
|
XCTFail("Expected selected workspace and focused terminal panel")
|
|
return
|
|
}
|
|
|
|
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
|
|
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
|
|
|
|
var promptCount = 0
|
|
manager.confirmCloseHandler = { _, _, _ in
|
|
promptCount += 1
|
|
return false
|
|
}
|
|
|
|
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
|
|
|
|
XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
|
|
XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
|
|
}
|
|
|
|
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
|
|
let manager = TabManager()
|
|
let firstWorkspace = manager.tabs[0]
|
|
let secondWorkspace = manager.addWorkspace()
|
|
manager.selectWorkspace(secondWorkspace)
|
|
|
|
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
|
XCTFail("Expected focused panel in selected workspace")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
|
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
|
|
|
manager.closeCurrentPanelWithConfirmation()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
|
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
|
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
|
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
|
}
|
|
|
|
func testCloseCurrentPanelPromptsBeforeClosingPinnedWorkspaceLastSurface() {
|
|
let manager = TabManager()
|
|
_ = manager.tabs[0]
|
|
let pinnedWorkspace = manager.addWorkspace()
|
|
manager.setPinned(pinnedWorkspace, pinned: true)
|
|
manager.selectWorkspace(pinnedWorkspace)
|
|
|
|
guard let pinnedPanelId = pinnedWorkspace.focusedPanelId else {
|
|
XCTFail("Expected focused panel in pinned workspace")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(manager.selectedTabId, pinnedWorkspace.id)
|
|
XCTAssertEqual(pinnedWorkspace.panels.count, 1)
|
|
|
|
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
|
|
manager.confirmCloseHandler = { title, message, acceptCmdD in
|
|
prompts.append((title, message, acceptCmdD))
|
|
return false
|
|
}
|
|
|
|
manager.closeCurrentPanelWithConfirmation()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(prompts.count, 1)
|
|
XCTAssertEqual(
|
|
prompts.first?.title,
|
|
String(localized: "dialog.closePinnedWorkspace.title", defaultValue: "Close pinned workspace?")
|
|
)
|
|
XCTAssertEqual(
|
|
prompts.first?.message,
|
|
String(
|
|
localized: "dialog.closePinnedWorkspace.message",
|
|
defaultValue: "This workspace is pinned. Closing it will close the workspace and all of its panels."
|
|
)
|
|
)
|
|
XCTAssertEqual(prompts.first?.acceptCmdD, false)
|
|
XCTAssertEqual(manager.tabs.count, 2)
|
|
XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id }))
|
|
XCTAssertEqual(manager.selectedTabId, pinnedWorkspace.id)
|
|
XCTAssertNotNil(pinnedWorkspace.panels[pinnedPanelId])
|
|
XCTAssertEqual(pinnedWorkspace.panels.count, 1)
|
|
}
|
|
|
|
func testCloseCurrentPanelClosesPinnedWorkspaceAfterConfirmation() {
|
|
let manager = TabManager()
|
|
let firstWorkspace = manager.tabs[0]
|
|
let pinnedWorkspace = manager.addWorkspace()
|
|
manager.setPinned(pinnedWorkspace, pinned: true)
|
|
manager.selectWorkspace(pinnedWorkspace)
|
|
|
|
guard let pinnedPanelId = pinnedWorkspace.focusedPanelId else {
|
|
XCTFail("Expected focused panel in pinned workspace")
|
|
return
|
|
}
|
|
|
|
manager.confirmCloseHandler = { _, _, _ in true }
|
|
|
|
manager.closeCurrentPanelWithConfirmation()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
|
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
|
XCTAssertNil(pinnedWorkspace.panels[pinnedPanelId])
|
|
XCTAssertTrue(pinnedWorkspace.panels.isEmpty)
|
|
}
|
|
|
|
func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() {
|
|
let defaults = UserDefaults.standard
|
|
let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
defer {
|
|
if let originalSetting {
|
|
defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
} else {
|
|
defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
}
|
|
}
|
|
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let initialPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace and focused panel")
|
|
return
|
|
}
|
|
|
|
let initialWorkspaceId = workspace.id
|
|
|
|
manager.closeCurrentPanelWithConfirmation()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
|
|
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
|
|
XCTAssertNil(workspace.panels[initialPanelId])
|
|
XCTAssertEqual(workspace.panels.count, 1)
|
|
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
|
|
}
|
|
|
|
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
|
|
let manager = TabManager()
|
|
let firstWorkspace = manager.tabs[0]
|
|
let secondWorkspace = manager.addWorkspace()
|
|
manager.selectWorkspace(secondWorkspace)
|
|
|
|
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
|
XCTFail("Expected focused panel in selected workspace")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
|
|
XCTAssertEqual(secondWorkspace.panels.count, 1)
|
|
|
|
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
|
|
XCTFail("Expected bonsplit surface ID for focused panel")
|
|
return
|
|
}
|
|
|
|
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
|
|
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
|
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
|
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
|
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
|
}
|
|
|
|
func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() {
|
|
let defaults = UserDefaults.standard
|
|
let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
defer {
|
|
if let originalSetting {
|
|
defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
} else {
|
|
defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey)
|
|
}
|
|
}
|
|
|
|
let manager = TabManager()
|
|
let firstWorkspace = manager.tabs[0]
|
|
let secondWorkspace = manager.addWorkspace()
|
|
manager.selectWorkspace(secondWorkspace)
|
|
|
|
guard let secondPanelId = secondWorkspace.focusedPanelId else {
|
|
XCTFail("Expected focused panel in selected workspace")
|
|
return
|
|
}
|
|
|
|
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
|
|
XCTFail("Expected bonsplit surface ID for focused panel")
|
|
return
|
|
}
|
|
|
|
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
|
|
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
|
|
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
|
|
XCTAssertNil(secondWorkspace.panels[secondPanelId])
|
|
XCTAssertTrue(secondWorkspace.panels.isEmpty)
|
|
}
|
|
|
|
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let initialPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace and focused panel")
|
|
return
|
|
}
|
|
|
|
let initialWorkspaceId = workspace.id
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(workspace.panels.count, 1)
|
|
|
|
XCTAssertTrue(workspace.closePanel(initialPanelId))
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.tabs.count, 1)
|
|
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
|
|
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
|
|
XCTAssertNil(workspace.panels[initialPanelId])
|
|
XCTAssertEqual(workspace.panels.count, 1)
|
|
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
|
|
}
|
|
|
|
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
|
|
let manager = TabManager()
|
|
let firstWorkspace = manager.tabs[0]
|
|
let secondWorkspace = manager.addWorkspace()
|
|
|
|
manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID())
|
|
|
|
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id])
|
|
}
|
|
|
|
func testCloseCurrentPanelClearsNotificationsForClosedSurface() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let initialPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace and focused panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: initialPanelId,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
|
|
|
|
manager.closeCurrentPanelWithConfirmation()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerNotificationFocusTests: XCTestCase {
|
|
func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
workspace.focusPanel(leftPanelId)
|
|
XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable")
|
|
XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed")
|
|
|
|
XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertFalse(
|
|
workspace.bonsplitController.isSplitZoomed,
|
|
"Expected notification focus to exit split zoom so the target pane becomes visible"
|
|
)
|
|
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused")
|
|
}
|
|
|
|
func testFocusTabFromNotificationReturnsFalseForMissingPanel() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace else {
|
|
XCTFail("Expected selected workspace")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID()))
|
|
}
|
|
|
|
func testFocusTabFromNotificationDismissesUnreadWithDismissFlash() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
let defaults = UserDefaults.standard
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
if let originalExperimentEnabled {
|
|
defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
}
|
|
if let originalExperimentTarget {
|
|
defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
}
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split terminal panels")
|
|
return
|
|
}
|
|
|
|
workspace.focusPanel(leftPanelId)
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: rightPanel.id,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: "Right pane should dismiss attention when focused from a notification"
|
|
)
|
|
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0)
|
|
|
|
XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
|
|
|
|
let expectation = XCTestExpectation(description: "notification focus flash")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
expectation.fulfill()
|
|
}
|
|
wait(for: [expectation], timeout: 1)
|
|
|
|
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 1)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, rightPanel.id)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
|
|
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
|
|
let tabId = UUID()
|
|
|
|
XCTAssertFalse(
|
|
TabManager.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: tabId,
|
|
selectedTabId: tabId
|
|
)
|
|
)
|
|
}
|
|
|
|
func testUnfocusesWhenPendingTabIsNotSelected() {
|
|
XCTAssertTrue(
|
|
TabManager.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: UUID(),
|
|
selectedTabId: UUID()
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
TabManager.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: UUID(),
|
|
selectedTabId: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerSurfaceCreationTests: XCTestCase {
|
|
func testNewSurfaceFocusesCreatedSurface() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace else {
|
|
XCTFail("Expected a selected workspace")
|
|
return
|
|
}
|
|
|
|
let beforePanels = Set(workspace.panels.keys)
|
|
manager.newSurface()
|
|
let afterPanels = Set(workspace.panels.keys)
|
|
|
|
let createdPanels = afterPanels.subtracting(beforePanels)
|
|
XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path")
|
|
guard let createdPanelId = createdPanels.first else { return }
|
|
|
|
XCTAssertEqual(
|
|
workspace.focusedPanelId,
|
|
createdPanelId,
|
|
"Expected newly created surface to be focused"
|
|
)
|
|
}
|
|
|
|
func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let paneId = workspace.bonsplitController.focusedPaneId else {
|
|
XCTFail("Expected focused workspace and pane")
|
|
return
|
|
}
|
|
|
|
// Add one extra surface so we verify append-to-end rather than first insert behavior.
|
|
_ = workspace.newTerminalSurface(inPane: paneId, focus: false)
|
|
|
|
guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else {
|
|
XCTFail("Expected browser panel to be created")
|
|
return
|
|
}
|
|
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
guard let lastSurfaceId = tabs.last?.id else {
|
|
XCTFail("Expected at least one surface in pane")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(
|
|
workspace.panelIdFromSurfaceId(lastSurfaceId),
|
|
browserPanelId,
|
|
"Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end"
|
|
)
|
|
XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
|
|
}
|
|
|
|
func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() {
|
|
let manager = TabManager()
|
|
guard let initialWorkspace = manager.selectedWorkspace else {
|
|
XCTFail("Expected initial selected workspace")
|
|
return
|
|
}
|
|
guard let url = URL(string: "https://example.com/pull/123") else {
|
|
XCTFail("Expected test URL to be valid")
|
|
return
|
|
}
|
|
|
|
let targetWorkspace = manager.addWorkspace(select: false)
|
|
manager.selectWorkspace(initialWorkspace)
|
|
let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count
|
|
let initialPanelCount = targetWorkspace.panels.count
|
|
|
|
guard let browserPanelId = manager.openBrowser(
|
|
inWorkspace: targetWorkspace.id,
|
|
url: url,
|
|
preferSplitRight: true,
|
|
insertAtEnd: true
|
|
) else {
|
|
XCTFail("Expected browser panel to be created in target workspace")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected")
|
|
XCTAssertEqual(
|
|
targetWorkspace.bonsplitController.allPaneIds.count,
|
|
initialPaneCount + 1,
|
|
"Expected split-right browser open to create a new pane"
|
|
)
|
|
XCTAssertEqual(
|
|
targetWorkspace.panels.count,
|
|
initialPanelCount + 1,
|
|
"Expected browser panel count to increase by one"
|
|
)
|
|
XCTAssertEqual(
|
|
targetWorkspace.focusedPanelId,
|
|
browserPanelId,
|
|
"Expected created browser panel to be focused in target workspace"
|
|
)
|
|
XCTAssertTrue(
|
|
targetWorkspace.panels[browserPanelId] is BrowserPanel,
|
|
"Expected created panel to be a browser panel"
|
|
)
|
|
}
|
|
|
|
func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
|
|
workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil,
|
|
let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id),
|
|
let url = URL(string: "https://example.com/pull/456") else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
let initialPaneCount = workspace.bonsplitController.allPaneIds.count
|
|
|
|
guard let browserPanelId = manager.openBrowser(
|
|
inWorkspace: workspace.id,
|
|
url: url,
|
|
preferSplitRight: true,
|
|
insertAtEnd: true
|
|
) else {
|
|
XCTFail("Expected browser panel to be created")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(
|
|
workspace.bonsplitController.allPaneIds.count,
|
|
initialPaneCount,
|
|
"Expected split-right browser open to reuse existing panes"
|
|
)
|
|
XCTAssertEqual(
|
|
workspace.paneId(forPanelId: browserPanelId),
|
|
topRightPaneId,
|
|
"Expected browser to open in the top-right pane when multiple splits already exist"
|
|
)
|
|
|
|
let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId)
|
|
guard let lastSurfaceId = targetPaneTabs.last?.id else {
|
|
XCTFail("Expected top-right pane to contain tabs")
|
|
return
|
|
}
|
|
XCTAssertEqual(
|
|
workspace.panelIdFromSurfaceId(lastSurfaceId),
|
|
browserPanelId,
|
|
"Expected browser surface to be appended at end in the reused top-right pane"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerEqualizeSplitsTests: XCTestCase {
|
|
func testEqualizeSplitsSetsEverySplitDividerToHalf() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
|
|
workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else {
|
|
XCTFail("Expected nested split setup to succeed")
|
|
return
|
|
}
|
|
|
|
let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
|
|
XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout")
|
|
|
|
for (index, split) in initialSplits.enumerated() {
|
|
guard let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected split ID to be a UUID")
|
|
return
|
|
}
|
|
let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId),
|
|
"Expected to seed divider position for split \(splitId)"
|
|
)
|
|
}
|
|
|
|
XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed")
|
|
|
|
let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
|
|
XCTAssertEqual(equalizedSplits.count, initialSplits.count)
|
|
for split in equalizedSplits {
|
|
XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class TabManagerResizeSplitsTests: XCTestCase {
|
|
func testResizeSplitMovesHorizontalDividerRightForFirstChildPane() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId),
|
|
"Expected to seed divider position"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 120),
|
|
"Expected resizeSplit to succeed for the right edge of the left pane"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertGreaterThan(
|
|
updatedSplit.dividerPosition,
|
|
0.5,
|
|
"Expected resizing the left pane to the right to move the divider toward the second child"
|
|
)
|
|
}
|
|
|
|
func testResizeSplitMovesHorizontalDividerLeftForSecondChildPane() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId),
|
|
"Expected to seed divider position"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: rightPanel.id, direction: .left, amount: 120),
|
|
"Expected resizeSplit to succeed for the left edge of the right pane"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertLessThan(
|
|
updatedSplit.dividerPosition,
|
|
0.5,
|
|
"Expected resizing the right pane to the left to move the divider toward the first child"
|
|
)
|
|
}
|
|
|
|
func testResizeSplitMovesVerticalDividerDownForFirstChildPane() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let topPanelId = workspace.focusedPanelId,
|
|
workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) != nil else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId),
|
|
"Expected to seed divider position"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: topPanelId, direction: .down, amount: 120),
|
|
"Expected resizeSplit to succeed for the bottom edge of the top pane"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertGreaterThan(
|
|
updatedSplit.dividerPosition,
|
|
0.5,
|
|
"Expected resizing the top pane downward to move the divider toward the second child"
|
|
)
|
|
}
|
|
|
|
func testResizeSplitMovesVerticalDividerUpForSecondChildPane() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let topPanelId = workspace.focusedPanelId,
|
|
let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId),
|
|
"Expected to seed divider position"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 120),
|
|
"Expected resizeSplit to succeed for the top edge of the bottom pane"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertLessThan(
|
|
updatedSplit.dividerPosition,
|
|
0.5,
|
|
"Expected resizing the bottom pane upward to move the divider toward the first child"
|
|
)
|
|
}
|
|
|
|
func testResizeSplitReturnsFalseWhenPaneHasNoBorderInDirection() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .left, amount: 120),
|
|
"Expected resizeSplit to fail when the pane has no adjacent border in that direction"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
XCTAssertEqual(updatedSplit.dividerPosition, split.dividerPosition, accuracy: 0.000_1)
|
|
}
|
|
|
|
func testResizeSplitClampsDividerPositionAtUpperBound() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.89, forSplit: splitId),
|
|
"Expected to seed divider position near upper bound"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 10_000),
|
|
"Expected resizeSplit to clamp instead of failing"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(updatedSplit.dividerPosition, 0.9, accuracy: 0.000_1)
|
|
}
|
|
|
|
func testResizeSplitClampsDividerPositionAtLowerBound() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let topPanelId = workspace.focusedPanelId,
|
|
let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
|
|
let splitId = UUID(uuidString: split.id) else {
|
|
XCTFail("Expected a split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
workspace.bonsplitController.setDividerPosition(0.11, forSplit: splitId),
|
|
"Expected to seed divider position near lower bound"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 10_000),
|
|
"Expected resizeSplit to clamp instead of failing"
|
|
)
|
|
|
|
guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else {
|
|
XCTFail("Expected updated split node in tree snapshot")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(updatedSplit.dividerPosition, 0.1, accuracy: 0.000_1)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
|
|
func testUsesFocusedTerminalWhenTerminalIsFocused() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused terminal")
|
|
return
|
|
}
|
|
|
|
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
|
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
|
|
}
|
|
|
|
func testFallsBackToTerminalWhenBrowserIsFocused() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanelId = workspace.focusedPanelId,
|
|
let paneId = workspace.paneId(forPanelId: terminalPanelId),
|
|
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
|
|
XCTFail("Expected selected workspace setup to succeed")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
|
|
|
|
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
|
XCTAssertEqual(
|
|
sourcePanel?.id,
|
|
terminalPanelId,
|
|
"Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
|
|
)
|
|
}
|
|
|
|
func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftTerminalPanelId = workspace.focusedPanelId,
|
|
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
|
|
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
|
|
XCTFail("Expected split setup to succeed")
|
|
return
|
|
}
|
|
|
|
workspace.focusPanel(leftTerminalPanelId)
|
|
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
|
|
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
|
|
|
|
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
|
XCTAssertEqual(
|
|
sourcePanel?.id,
|
|
leftTerminalPanelId,
|
|
"Expected workspace inheritance source to use last focused terminal across panes"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
final class TabManagerFocusedNotificationIndicatorTests: XCTestCase {
|
|
func testFocusPanelDismissesUnreadNotificationWithDismissFlash() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
let defaults = UserDefaults.standard
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
if let originalExperimentEnabled {
|
|
defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
}
|
|
if let originalExperimentTarget {
|
|
defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
}
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split terminal panels")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: leftPanelId,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: "Left pane should dismiss attention when focused"
|
|
)
|
|
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: leftPanelId))
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId))
|
|
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0)
|
|
|
|
workspace.focusPanel(leftPanelId)
|
|
|
|
XCTAssertEqual(workspace.focusedPanelId, leftPanelId)
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: leftPanelId))
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId))
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 1)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, leftPanelId)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss)
|
|
}
|
|
|
|
func testDismissNotificationOnDirectInteractionClearsFocusedNotificationIndicator() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId)
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
|
|
XCTAssertTrue(
|
|
manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId)
|
|
)
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
}
|
|
|
|
func testDismissNotificationOnDirectInteractionTriggersDismissFlashForFocusedIndicatorOnly() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
let defaults = UserDefaults.standard
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
if let originalExperimentEnabled {
|
|
defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey)
|
|
}
|
|
if let originalExperimentTarget {
|
|
defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
} else {
|
|
defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey)
|
|
}
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId)
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId))
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0)
|
|
|
|
XCTAssertTrue(
|
|
manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId)
|
|
)
|
|
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
XCTAssertEqual(
|
|
workspace.tmuxWorkspaceFlashToken,
|
|
1,
|
|
"Expected dismissing a focused-read indicator to emit a dismiss flash even when unread is already cleared"
|
|
)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, panelId)
|
|
XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
|
|
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
|
|
let manager = TabManager()
|
|
guard let workspace1 = manager.selectedWorkspace,
|
|
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else {
|
|
XCTFail("Expected initial workspace and browser panel")
|
|
return
|
|
}
|
|
|
|
drainMainQueue()
|
|
XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
|
|
drainMainQueue()
|
|
|
|
let workspace2 = manager.addWorkspace()
|
|
XCTAssertEqual(manager.selectedTabId, workspace2.id)
|
|
|
|
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.selectedTabId, workspace1.id)
|
|
XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
|
|
}
|
|
|
|
func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() {
|
|
let manager = TabManager()
|
|
guard let originalWorkspace = manager.selectedWorkspace,
|
|
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else {
|
|
XCTFail("Expected initial workspace and browser panel")
|
|
return
|
|
}
|
|
|
|
drainMainQueue()
|
|
XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true))
|
|
drainMainQueue()
|
|
|
|
let currentWorkspace = manager.addWorkspace()
|
|
manager.closeWorkspace(originalWorkspace)
|
|
|
|
XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
|
|
XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id }))
|
|
|
|
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
|
|
XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace))
|
|
}
|
|
|
|
func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() {
|
|
let manager = TabManager()
|
|
guard let workspace1 = manager.selectedWorkspace,
|
|
let sourcePanelId = workspace1.focusedPanelId,
|
|
let splitBrowserId = manager.newBrowserSplit(
|
|
tabId: workspace1.id,
|
|
fromPanelId: sourcePanelId,
|
|
orientation: .horizontal,
|
|
insertFirst: false,
|
|
url: URL(string: "https://example.com/collapsed-split")
|
|
) else {
|
|
XCTFail("Expected to create browser split")
|
|
return
|
|
}
|
|
|
|
drainMainQueue()
|
|
XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true))
|
|
drainMainQueue()
|
|
|
|
let workspace2 = manager.addWorkspace()
|
|
XCTAssertEqual(manager.selectedTabId, workspace2.id)
|
|
|
|
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.selectedTabId, workspace1.id)
|
|
XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
|
|
}
|
|
|
|
func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() {
|
|
let manager = TabManager()
|
|
guard let workspace1 = manager.selectedWorkspace,
|
|
let preReopenPanelId = workspace1.focusedPanelId,
|
|
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else {
|
|
XCTFail("Expected initial workspace state and browser panel")
|
|
return
|
|
}
|
|
|
|
drainMainQueue()
|
|
XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
|
|
drainMainQueue()
|
|
|
|
let panelIdsBeforeReopen = Set(workspace1.panels.keys)
|
|
let workspace2 = manager.addWorkspace()
|
|
XCTAssertEqual(manager.selectedTabId, workspace2.id)
|
|
|
|
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
|
|
guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else {
|
|
XCTFail("Expected reopened browser panel ID")
|
|
return
|
|
}
|
|
|
|
// Simulate one delayed stale focus callback from the panel that was focused before reopen.
|
|
DispatchQueue.main.async {
|
|
workspace1.focusPanel(preReopenPanelId)
|
|
}
|
|
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.selectedTabId, workspace1.id)
|
|
XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId)
|
|
XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel)
|
|
}
|
|
|
|
func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() {
|
|
let manager = TabManager()
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let preReopenPanelId = workspace.focusedPanelId,
|
|
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else {
|
|
XCTFail("Expected initial workspace state and browser panel")
|
|
return
|
|
}
|
|
|
|
drainMainQueue()
|
|
XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true))
|
|
drainMainQueue()
|
|
|
|
let panelIdsBeforeReopen = Set(workspace.panels.keys)
|
|
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
|
|
guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else {
|
|
XCTFail("Expected reopened browser panel ID")
|
|
return
|
|
}
|
|
|
|
// Simulate one delayed stale focus callback from the panel that was focused before reopen.
|
|
DispatchQueue.main.async {
|
|
workspace.focusPanel(preReopenPanelId)
|
|
}
|
|
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
drainMainQueue()
|
|
|
|
XCTAssertEqual(manager.selectedTabId, workspace.id)
|
|
XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId)
|
|
XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel)
|
|
}
|
|
|
|
private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool {
|
|
guard let focusedPanelId = workspace.focusedPanelId else { return false }
|
|
return workspace.panels[focusedPanelId] is BrowserPanel
|
|
}
|
|
|
|
private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set<UUID>) -> UUID? {
|
|
let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds)
|
|
guard newPanelIds.count == 1 else { return nil }
|
|
return newPanelIds.first
|
|
}
|
|
|
|
private func drainMainQueue() {
|
|
let expectation = expectation(description: "drain main queue")
|
|
DispatchQueue.main.async {
|
|
expectation.fulfill()
|
|
}
|
|
wait(for: [expectation], timeout: 1.0)
|
|
}
|
|
}
|