Files
Termind/FrameworkTests/TerminalContextCoordinatorTests.swift

228 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import Testing
@Suite @MainActor
struct TerminalContextCoordinatorTests {
private func context(_ pane: String = "a", instance: String = "one") -> TerminalShellContext {
.init(
identity: .init(backend: "test", server: "server", session: "session", pane: pane, shellInstance: instance),
shell: "bash", directory: "/\(pane)", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft])
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
func commandsKeepTheReportedDirectoryWithoutKeepingOperationTickets(backend: String) throws {
let model = TerminalContextCoordinator()
var shell = TerminalShellContext(
identity: .init(backend: backend, server: "s", session: "s", pane: "a", shellInstance: "one"),
shell: "bash", directory: "/before", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft])
let status: TerminalRuntimeUpdate.Status = backend == "shell" ? .ordinary : .multiplexer
func receive(_ events: [TerminalContextEvent] = []) {
model.receive(
.init(status: status, active: shell.identity, contexts: [shell], events: events),
generation: model.generation)
}
receive()
let draft = try #require(model.ticket(requiring: .replaceDraft))
let directory = try #require(model.ticket(requiring: .directory))
shell.promptReady = false
shell.capabilities = [.commandLifecycle]
shell.sequence = 2
receive([.init(context: shell.identity, sequence: 2, payload: .commandStarted("sleep 10"))])
#expect(model.directory == "/before")
#expect(model.ticket(requiring: .readDraft) == nil)
#expect(model.ticket(requiring: .replaceDraft) == nil)
#expect(model.ticket(requiring: .directory) == nil)
#expect(!model.isCurrent(draft, requiring: .replaceDraft))
#expect(!model.isCurrent(directory, requiring: .directory))
receive()
#expect(model.directory == "/before")
// A chpwd event may arrive before the next prompt (for example, zsh's cd hook).
shell.directory = "/after"
shell.sequence = 3
receive([.init(context: shell.identity, sequence: 3, payload: .directory("/after"))])
#expect(model.directory == "/after")
#expect(model.ticket(requiring: .replaceDraft) == nil)
shell.promptReady = true
shell.sequence = 5
shell.capabilities = [.directory, .commandLifecycle, .readDraft, .replaceDraft]
receive([
.init(context: shell.identity, sequence: 4, payload: .commandFinished(0, duration: 10)),
.init(context: shell.identity, sequence: 5, payload: .promptReady),
])
#expect(model.directory == "/after")
#expect(model.ticket(requiring: .replaceDraft) != nil)
#expect(!model.isCurrent(draft, requiring: .replaceDraft))
var replacement = context("b", instance: "new")
replacement.directory = nil
model.receive(
.init(status: status, active: replacement.identity, contexts: [shell, replacement]),
generation: model.generation)
#expect(model.directory == nil)
receive()
#expect(model.directory == "/after")
model.receive(.init(status: .multiplexer, contexts: [shell]), generation: model.generation)
#expect(model.directory == nil)
receive()
model.reset()
#expect(model.directory == nil)
}
@Test func activeContextIsNotInferredFromEventSource() {
let model = TerminalContextCoordinator()
let a = context()
let b = context("b")
let update = TerminalRuntimeUpdate(
status: .multiplexer, active: a.identity, contexts: [a, b],
events: [.init(context: b.identity, sequence: 1, payload: .directory("/wrong"))])
model.receive(update, generation: model.generation)
#expect(model.directory == "/a")
#expect(model.active?.identity == a.identity)
}
@Test func ticketsExpireOnInputSwitchDisconnectAndCapabilityLoss() throws {
let model = TerminalContextCoordinator()
let a = context()
model.receive(.init(status: .ordinary, active: a.identity, contexts: [a]), generation: model.generation)
let ticket = try #require(model.ticket(requiring: .readDraft))
model.noteInput()
#expect(!model.isCurrent(ticket, requiring: .readDraft))
let next = try #require(model.ticket(requiring: .readDraft))
model.receive(.init(status: .unavailable), generation: model.generation)
#expect(model.directory == nil)
#expect(!model.isCurrent(next, requiring: .readDraft))
let old = model.generation
model.reset()
model.receive(.init(status: .ordinary, active: a.identity, contexts: [a]), generation: old)
#expect(model.active == nil)
}
@Test func journalReplayDoesNotReannounceOldCommandsAndGapsCannotCompleteThem() {
let model = TerminalContextCoordinator()
var a = context()
a.sequence = 9
let past = TerminalContextEvent(context: a.identity, sequence: 8, payload: .commandStarted("old"))
#expect(
model.receive(
.init(status: .multiplexer, active: a.identity, contexts: [a], events: [past]),
generation: model.generation
).isEmpty)
let start = TerminalContextEvent(context: a.identity, sequence: 10, payload: .commandStarted("new"))
let finish = TerminalContextEvent(
context: a.identity, sequence: 12, payload: .commandFinished(0, duration: 1))
a.sequence = 12
let accepted = model.receive(
.init(status: .multiplexer, active: a.identity, contexts: [a], events: [start, finish]),
generation: model.generation)
#expect(accepted == [start])
#expect(
model.receive(
.init(status: .multiplexer, active: a.identity, contexts: [a], events: [start, finish]),
generation: model.generation
).isEmpty)
}
@Test func independentPanesCompleteWithoutOverwritingEachOther() {
let model = TerminalContextCoordinator()
let monitor = TerminalCommandMonitor()
monitor.beginConnection()
let a = context()
let b = context("b")
model.receive(.init(status: .multiplexer, active: a.identity, contexts: [a, b]), generation: model.generation)
var nextA = a
var nextB = b
nextA.sequence = 3
nextB.sequence = 3
let events: [TerminalContextEvent] = [
.init(context: a.identity, sequence: 2, payload: .commandStarted("first")),
.init(context: b.identity, sequence: 2, payload: .commandStarted("second")),
.init(context: a.identity, sequence: 3, payload: .commandFinished(0, duration: 10)),
.init(context: b.identity, sequence: 3, payload: .commandFinished(1, duration: 20)),
]
monitor.receiveRuntimeEvents(
model.receive(
.init(status: .multiplexer, active: a.identity, contexts: [nextA, nextB], events: events),
generation: model.generation))
#expect(monitor.unreadCompletionCount == 2)
monitor.receiveOSC133(exitCode: 0, durationNanos: 20_000_000_000)
#expect(monitor.unreadCompletionCount == 2)
}
@Test func anUnconfirmedForegroundDoesNotForgetTheOuterShellCommand() {
let model = TerminalContextCoordinator()
var shell = context()
shell = .init(
identity: .init(backend: "shell", server: "c", session: "c", pane: "shell", shellInstance: "s"),
shell: shell.shell, directory: shell.directory, home: shell.home, promptReady: false,
sequence: 1, capabilities: [.commandLifecycle])
model.receive(
.init(
status: .unknown, contexts: [shell],
events: [.init(context: shell.identity, sequence: 1, payload: .commandStarted("sleep 2"))]),
generation: model.generation)
model.receive(.init(status: .unknown), generation: model.generation)
let event = TerminalContextEvent(
context: shell.identity, sequence: 2, payload: .commandFinished(0, duration: 2))
shell.sequence = 2
#expect(
model.receive(
.init(status: .unknown, contexts: [shell], events: [event]), generation: model.generation) == [event])
}
@Test func aRuntimeAdapterDoesNotNeedATmuxDataModel() async throws {
struct Adapter: TerminalRuntimeAdapter {
let update: TerminalRuntimeUpdate
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { update }
}
let model = TerminalContextCoordinator()
let context = context("different-backend")
try await model.refresh(
using: Adapter(update: .init(status: .multiplexer, active: context.identity, contexts: [context])),
terminal: "opaque-terminal-binding")
#expect(model.directory == "/different-backend")
#expect(model.ticket(requiring: .replaceDraft) != nil)
var old = context
old.sequence = 0
old.directory = "/stale"
model.receive(.init(status: .multiplexer, active: old.identity, contexts: [old]), generation: model.generation)
#expect(model.directory == "/different-backend")
}
@Test func aCommandBetweenPollsInvalidatesDraftsEvenWhenThePromptLooksUnchanged() throws {
let model = TerminalContextCoordinator()
var shell = context()
model.receive(
.init(status: .multiplexer, active: shell.identity, contexts: [shell]), generation: model.generation)
let draft = try #require(model.ticket(requiring: .replaceDraft))
shell.sequence = 2
model.receive(
.init(
status: .multiplexer, active: shell.identity, contexts: [shell],
events: [
.init(context: shell.identity, sequence: 2, payload: .draft(request: "request", value: "text"))
]),
generation: model.generation)
#expect(model.isCurrent(draft, requiring: .replaceDraft))
shell.sequence = 5
model.receive(
.init(
status: .multiplexer, active: shell.identity, contexts: [shell],
events: [
.init(context: shell.identity, sequence: 3, payload: .commandStarted("true")),
.init(context: shell.identity, sequence: 4, payload: .commandFinished(0, duration: 0)),
.init(context: shell.identity, sequence: 5, payload: .promptReady),
]),
generation: model.generation)
#expect(!model.isCurrent(draft, requiring: .replaceDraft))
#expect(model.directory == shell.directory)
}
}