初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)

- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连
- M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire
- M1.5: tmux -CC 原生 tab(MVP)
- 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/
- 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口)
- 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名
- 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
@testable import GhosttyTerminal
import Darwin
import Foundation
import GhosttyKit
import Testing
struct InMemoryTerminalSessionOutputQueueTests {
@Test
func `receive returns before surface write completes`() {
let writeStarted = DispatchSemaphore(value: 0)
let allowWriteToFinish = DispatchSemaphore(value: 0)
let session = makeSession { _, _ in
writeStarted.signal()
allowWriteToFinish.wait()
}
session.setSurface(testSurface(1))
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
allowWriteToFinish.signal()
}
let start = ProcessInfo.processInfo.systemUptime
session.receive(Data("hello".utf8))
let elapsed = ProcessInfo.processInfo.systemUptime - start
#expect(elapsed < 0.2)
#expect(writeStarted.wait(timeout: .now() + 1) == .success)
allowWriteToFinish.signal()
session.waitForPendingOutput()
}
@Test
func `writes and process exit preserve enqueue order`() {
let events = LockedValues<String>()
let session = InMemoryTerminalSession(
write: { _ in },
resize: { _ in },
surfaceWrite: { _, data in
events.append(String(decoding: data, as: UTF8.self))
},
processExit: { _, exitCode, runtimeMilliseconds in
events.append("exit:\(exitCode):\(runtimeMilliseconds)")
}
)
session.setSurface(testSurface(2))
session.receive("first")
session.receive("second")
session.finish(exitCode: 7, runtimeMilliseconds: 42)
session.waitForPendingOutput()
#expect(events.values == ["first", "second", "exit:7:42"])
}
@Test
func `surface teardown waits for active write and drops queued stale writes`() {
let firstWriteStarted = DispatchSemaphore(value: 0)
let allowFirstWriteToFinish = DispatchSemaphore(value: 0)
let clearFinished = DispatchSemaphore(value: 0)
let writes = LockedValues<String>()
let surface = SendableSurface(testSurface(3))
let session = makeSession { _, data in
let value = String(decoding: data, as: UTF8.self)
writes.append(value)
if value == "first" {
firstWriteStarted.signal()
allowFirstWriteToFinish.wait()
}
}
session.setSurface(surface.rawValue)
session.receive("first")
session.receive("stale")
#expect(firstWriteStarted.wait(timeout: .now() + 1) == .success)
DispatchQueue.global().async {
session.clearSurface(ifMatches: surface.rawValue)
clearFinished.signal()
}
let clearDeadline = ProcessInfo.processInfo.systemUptime + 1
while session.currentSurface != nil,
ProcessInfo.processInfo.systemUptime < clearDeadline
{
sched_yield()
}
#expect(session.currentSurface == nil)
allowFirstWriteToFinish.signal()
#expect(clearFinished.wait(timeout: .now() + 1) == .success)
session.waitForPendingOutput()
#expect(writes.values == ["first"])
}
@Test
func `blocked session does not block another session`() {
let firstWriteStarted = DispatchSemaphore(value: 0)
let allowFirstWriteToFinish = DispatchSemaphore(value: 0)
let secondWriteFinished = DispatchSemaphore(value: 0)
let firstSession = makeSession { _, _ in
firstWriteStarted.signal()
allowFirstWriteToFinish.wait()
}
let secondSession = makeSession { _, _ in
secondWriteFinished.signal()
}
firstSession.setSurface(testSurface(4))
secondSession.setSurface(testSurface(5))
firstSession.receive("blocked")
#expect(firstWriteStarted.wait(timeout: .now() + 1) == .success)
secondSession.receive("independent")
#expect(secondWriteFinished.wait(timeout: .now() + 1) == .success)
allowFirstWriteToFinish.signal()
firstSession.waitForPendingOutput()
secondSession.waitForPendingOutput()
}
}
private func makeSession(
surfaceWrite: @escaping InMemoryTerminalSurfaceAccess.Write
) -> InMemoryTerminalSession {
InMemoryTerminalSession(
write: { _ in },
resize: { _ in },
surfaceWrite: surfaceWrite
)
}
private func testSurface(_ address: Int) -> ghostty_surface_t {
UnsafeMutableRawPointer(bitPattern: address)!
}
private struct SendableSurface: @unchecked Sendable {
let rawValue: ghostty_surface_t
init(_ rawValue: ghostty_surface_t) {
self.rawValue = rawValue
}
}
private final class LockedValues<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var storage: [Value] = []
var values: [Value] {
lock.lock()
defer { lock.unlock() }
return storage
}
func append(_ value: Value) {
lock.lock()
storage.append(value)
lock.unlock()
}
}

View File

@@ -0,0 +1,25 @@
@testable import GhosttyTerminal
import Testing
@MainActor
struct InMemoryTerminalSessionViewportTests {
/// `readViewportText()` MUST return `nil` (not crash) when no surface is
/// attached. This is the canonical pre-surface / post-surface-teardown
/// state consumers may call `readViewportText` from any thread that
/// holds a reference, and the contract is "nil means no surface."
@Test
func `read viewport text returns nil before surface attached`() {
let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
#expect(session.readViewportText() == nil)
}
/// After clearing the surface, the read MUST go back to returning `nil`.
/// Together with the test above this pins the surface-presence semantics
/// of the public API.
@Test
func `read viewport text returns nil after surface cleared`() {
let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
session.clearSurface(ifMatches: nil)
#expect(session.readViewportText() == nil)
}
}

View File

@@ -0,0 +1,446 @@
import Foundation
import GhosttyTerminal
@testable import ShellCraftKit
import Testing
struct ShellCraftKitTests {
@Test
func `styled prompt uses visible column width`() {
let shell = ShellDefinition(
prompt: "\u{1B}[38;5;110mcolor\u{1B}[0m > ",
welcomeMessage: ""
) {}
#expect(shell.promptDisplayWidth == 8)
}
@Test
func `terminal display width counts wide characters`() {
#expect("abc".terminalDisplayWidth == 3)
#expect("你好".terminalDisplayWidth == 4)
#expect("a你b好".terminalDisplayWidth == 6)
#expect("\u{1B}[31m红色\u{1B}[0m".terminalDisplayWidth == 4)
}
@Test
func `cursor column uses display width instead of character count`() {
#expect(
terminalCursorColumn(
promptDisplayWidth: 8,
input: "测试",
cursorPosition: 2
) == 13
)
#expect(
terminalCursorColumn(
promptDisplayWidth: 8,
input: "a测b",
cursorPosition: 2
) == 12
)
#expect(
terminalCursorColumn(
promptDisplayWidth: 8,
input: "你好吗",
cursorPosition: 1
) == 11
)
}
@Test
func `rendered input state tracks wrapped lines and cursor placement`() {
let state = terminalRenderedInputState(
promptDisplayWidth: 18,
input: "hello world",
cursorPosition: 11,
terminalColumns: 20
)
#expect(state.totalLineCount == 2)
#expect(state.cursorLineOffset == 1)
#expect(state.cursorColumn == 10)
}
@Test
func `rendered input state handles prompt only wrapping`() {
let state = terminalRenderedInputState(
promptDisplayWidth: 18,
input: "",
cursorPosition: 0,
terminalColumns: 10
)
#expect(state.totalLineCount == 2)
#expect(state.cursorLineOffset == 1)
#expect(state.cursorColumn == 9)
}
@Test
func `wrapped terminal line count handles exact boundary`() {
#expect(wrappedTerminalLineCount(displayWidth: 20, terminalColumns: 20) == 1)
#expect(wrappedTerminalLineCount(displayWidth: 21, terminalColumns: 20) == 2)
}
@Test
func `rendered input state keeps cursor on boundary without trailing content`() {
let state = terminalRenderedInputState(
promptDisplayWidth: 18,
input: "ab",
cursorPosition: 2,
terminalColumns: 20
)
#expect(state.totalLineCount == 1)
#expect(state.cursorLineOffset == 0)
#expect(state.cursorColumn == 20)
}
@Test
func `rendered input state wraps boundary cursor when trailing content exists`() {
let state = terminalRenderedInputState(
promptDisplayWidth: 18,
input: "abc",
cursorPosition: 2,
terminalColumns: 20
)
#expect(state.totalLineCount == 2)
#expect(state.cursorLineOffset == 1)
#expect(state.cursorColumn == 1)
}
@Test
func `incremental append is allowed for tail insertion`() {
#expect(
canIncrementallyAppendInput(
previousInput: "hello",
previousCursorPosition: 5,
insertedText: " world"
)
)
#expect(
canIncrementallyAppendInput(
previousInput: "ni",
previousCursorPosition: 2,
insertedText: "你好"
)
)
}
@Test
func `incremental append falls back for mid line or control input`() {
#expect(
!canIncrementallyAppendInput(
previousInput: "hello",
previousCursorPosition: 2,
insertedText: "X"
)
)
#expect(
!canIncrementallyAppendInput(
previousInput: "hello",
previousCursorPosition: 5,
insertedText: "\t"
)
)
}
@Test
func `tab expansion uses visible cursor column`() {
#expect(
terminalExpandedTabText(
promptDisplayWidth: 2,
input: "abc",
cursorPosition: 3,
terminalColumns: 80
) == " "
)
#expect(
terminalExpandedTabText(
promptDisplayWidth: 7,
input: "",
cursorPosition: 0,
terminalColumns: 80
) == " "
)
}
@Test
func `tab expansion respects wrapped cursor column`() {
#expect(
terminalExpandedTabText(
promptDisplayWidth: 18,
input: "ab",
cursorPosition: 2,
terminalColumns: 20
) == String(repeating: " ", count: 5)
)
}
@Test
func `meta editing action recognizes word sequences`() {
#expect(terminalMetaEditingAction(for: 0x7F) == .deleteBackwardWord)
#expect(terminalMetaEditingAction(for: 0x08) == .deleteBackwardWord)
#expect(terminalMetaEditingAction(for: 0x62) == .moveBackwardWord)
#expect(terminalMetaEditingAction(for: 0x66) == .moveForwardWord)
#expect(terminalMetaEditingAction(for: 0x64) == .deleteForwardWord)
#expect(terminalMetaEditingAction(for: 0x42) == nil)
#expect(terminalMetaEditingAction(for: 0x78) == nil)
}
@Test
func `csi editing action recognizes modified arrow word sequences`() {
#expect(
terminalCSIEditingAction(params: Data("1;3".utf8), finalByte: 0x44)
== .moveCursorBackwardWord
)
#expect(
terminalCSIEditingAction(params: Data("1;3".utf8), finalByte: 0x43)
== .moveCursorForwardWord
)
#expect(
terminalCSIEditingAction(params: Data(), finalByte: 0x44)
== .moveCursorLeft
)
#expect(
terminalCSIEditingAction(params: Data(), finalByte: 0x43)
== .moveCursorRight
)
#expect(
terminalCSIEditingAction(params: Data("3".utf8), finalByte: 0x7E)
== .deleteForward
)
#expect(
terminalCSIEditingAction(params: Data("1;4".utf8), finalByte: 0x44)
== .moveCursorBackwardWord
)
#expect(
terminalCSIEditingAction(params: Data("3;3".utf8), finalByte: 0x7E)
== .deleteForwardWord
)
}
@Test
func `csi modifier detection matches alt suffix`() {
#expect(terminalCSIHasAltModifier(Data("1;3".utf8)))
#expect(terminalCSIHasAltModifier(Data("1;4".utf8)))
#expect(!terminalCSIHasAltModifier(Data("1;5".utf8)))
#expect(!terminalCSIHasAltModifier(Data("3".utf8)))
#expect(!terminalCSIHasAltModifier(Data()))
}
@Test
func `word boundaries treat punctuation as separators for meta motion`() {
#expect(terminalPreviousWordBoundary(in: "alpha beta", from: 10) == 6)
#expect(terminalPreviousWordBoundary(in: "alpha beta ", from: 12) == 6)
#expect(terminalNextWordBoundary(in: "alpha beta", from: 0) == 5)
#expect(terminalNextWordBoundary(in: "alpha beta", from: 5) == 12)
#expect(terminalPreviousWordBoundary(in: "foo-bar", from: 7) == 4)
#expect(terminalNextWordBoundary(in: "foo-bar", from: 0) == 3)
#expect(terminalPreviousWordBoundary(in: "héllo wörld", from: 11) == 6)
}
@Test
func `shell word boundaries remain whitespace delimited for control W`() {
#expect(terminalPreviousShellWordBoundary(in: "foo-bar baz", from: 11) == 8)
#expect(terminalPreviousShellWordBoundary(in: "alpha beta ", from: 12) == 6)
#expect(terminalNextShellWordBoundary(in: "alpha beta", from: 5) == 12)
}
@Test
func `delete backward word removes previous word and trailing spaces`() {
let result = terminalDeleteBackwardWord(
input: "alpha beta ",
cursorPosition: 12
)
#expect(result.input == "alpha ")
#expect(result.cursorPosition == 6)
}
@Test
func `delete forward word removes next word and leading spaces`() {
let result = terminalDeleteForwardWord(
input: "alpha beta gamma",
cursorPosition: 5
)
#expect(result.input == "alpha gamma")
#expect(result.cursorPosition == 5)
}
@Test
func `delete backward shell word removes previous whitespace delimited token`() {
let result = terminalDeleteBackwardShellWord(
input: "foo-bar baz ",
cursorPosition: 13
)
#expect(result.input == "foo-bar ")
#expect(result.cursorPosition == 8)
}
@Test
func `sandbox shell supports exit and styled fallback`() {
let viewport = InMemoryTerminalViewport(
columns: 80,
rows: 24,
widthPixels: 0,
heightPixels: 0
)
switch defaultSandboxShell.processCommand(
"exit",
username: "tester",
terminalSize: viewport
) {
case .exit:
break
default:
Issue.record("expected sandbox shell exit command to terminate the session")
}
if case let .output(message) = defaultSandboxShell.processCommand(
"missing-command",
username: "tester",
terminalSize: viewport
) {
#expect(message.contains("\u{1B}["))
#expect(message.contains("missing-command"))
} else {
Issue.record("expected fallback command result to produce output")
}
}
// MARK: - decodeUTF8Incrementally
@Test
func `utf 8 incremental decodes complete ASCII`() {
let (text, leftover) = decodeUTF8Incrementally(Data("hello".utf8))
#expect(text == "hello")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental decodes complete chinese`() {
let (text, leftover) = decodeUTF8Incrementally(Data("你好".utf8))
#expect(text == "你好")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental retains incomplete three byte sequence`() {
// "" is E4 BD A0 send only first 2 bytes
let partial = Data([0xE4, 0xBD])
let (text1, leftover1) = decodeUTF8Incrementally(partial)
#expect(text1 == "")
#expect(leftover1 == partial)
// Now complete the sequence
let full = leftover1 + Data([0xA0])
let (text2, leftover2) = decodeUTF8Incrementally(full)
#expect(text2 == "")
#expect(leftover2.isEmpty)
}
@Test
func `utf 8 incremental retains incomplete four byte sequence`() {
// 😀 is F0 9F 98 80 send only first 3 bytes
let partial = Data([0xF0, 0x9F, 0x98])
let (text1, leftover1) = decodeUTF8Incrementally(partial)
#expect(text1 == "")
#expect(leftover1 == partial)
let full = leftover1 + Data([0x80])
let (text2, leftover2) = decodeUTF8Incrementally(full)
#expect(text2 == "😀")
#expect(leftover2.isEmpty)
}
@Test
func `utf 8 incremental skips illegal lead byte FF`() {
let (text, leftover) = decodeUTF8Incrementally(Data([0xFF]))
#expect(text == "")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental skips overlong lead C 0 C 1`() {
// 0xC0 and 0xC1 are overlong, should be skipped
let input = Data([0xC0, 0x41, 0xC1, 0x42])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "AB")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental skips lead F 5 plus`() {
let input = Data([0xF5, 0x41, 0xF6, 0x42, 0xF7, 0x43])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "ABC")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental skips only lead byte on invalid combination`() {
// 0xE4 expects 2 continuation bytes, but next bytes are ASCII
let input = Data([0xE4, 0x41, 0x42])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "AB")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental preserves valid text after illegal byte`() {
let input = Data([0xFF, 0x68, 0x65, 0x6C, 0x6C, 0x6F]) // 0xFF + "hello"
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "hello")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental handles empty data`() {
let (text, leftover) = decodeUTF8Incrementally(Data())
#expect(text == "")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental handles mixed valid and incomplete`() {
// "ab" + incomplete 3-byte lead
let input = Data([0x61, 0x62, 0xE4, 0xBD])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "ab")
#expect(leftover == Data([0xE4, 0xBD]))
}
@Test
func `utf 8 incremental decomposed unicode`() {
// e (0x65) + combining acute accent U+0301 (0xCC 0x81)
let input = Data([0x65, 0xCC, 0x81])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "e\u{0301}")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental skips lead with non continuation tail`() {
// 0xE4 is 3-byte lead, but 0x41 is ASCII not a valid continuation
let input = Data([0xE4, 0x41])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "A")
#expect(leftover.isEmpty)
}
@Test
func `utf 8 incremental skips four byte lead with invalid tail`() {
// 0xF0 is 4-byte lead, but 0x41/0x42 are ASCII
let input = Data([0xF0, 0x41, 0x42])
let (text, leftover) = decodeUTF8Incrementally(input)
#expect(text == "AB")
#expect(leftover.isEmpty)
}
}

View File

@@ -0,0 +1,43 @@
@testable import GhosttyTerminal
import Testing
@Suite(.serialized)
struct TerminalDebugLogTests {
@Test
func `logging is disabled by default`() {
withRestoredDebugLogState {
#expect(!TerminalDebugLog.isEnabled)
#expect(TerminalDebugLog.categories == .standard)
#expect(!TerminalDebugLog.categories.contains(.render))
}
}
@Test
func `logging can be toggled and reconfigured`() {
withRestoredDebugLogState {
TerminalDebugLog.disable()
#expect(!TerminalDebugLog.isEnabled)
TerminalDebugLog.enable(.all)
TerminalDebugLog.sink = { _ in }
#expect(TerminalDebugLog.isEnabled)
#expect(TerminalDebugLog.categories == .all)
}
}
}
private func withRestoredDebugLogState(
_ body: () -> Void
) {
let originalEnabled = TerminalDebugLog.isEnabled
let originalCategories = TerminalDebugLog.categories
let originalSink = TerminalDebugLog.sink
defer {
TerminalDebugLog.isEnabled = originalEnabled
TerminalDebugLog.categories = originalCategories
TerminalDebugLog.sink = originalSink
}
body()
}

View File

@@ -0,0 +1,372 @@
import AppKit
import Foundation
import GhosttyKit
@testable import GhosttyTerminal
import Testing
struct TerminalHardwareKeyRouterTests {
@Test
func `routes UI kit arrow keys directly for in memory backends`() {
let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x50,
backend: .inMemory(session)
) == .data(Data("\u{1B}[D".utf8))
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x52,
backend: .inMemory(session)
) == .data(Data("\u{1B}[A".utf8))
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x2A,
backend: .inMemory(session)
) == .data(Data([0x7F]))
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x2B,
backend: .inMemory(session)
) == .data(Data([0x09]))
)
}
@Test
func `routes UI kit keys to ghostty for exec backends`() {
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x50,
backend: .exec
) == .ghostty(GHOSTTY_KEY_ARROW_LEFT)
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x04,
backend: .exec
) == .ghostty(GHOSTTY_KEY_A)
)
}
@Test
func `routes modified UI kit arrow keys to ghostty for in memory backends`() {
let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x50,
backend: .inMemory(session),
modifiers: .alt
) == .ghostty(GHOSTTY_KEY_ARROW_LEFT)
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x4F,
backend: .inMemory(session),
modifiers: .ctrl
) == .ghostty(GHOSTTY_KEY_ARROW_RIGHT)
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x29,
backend: .inMemory(session),
modifiers: .super_
) == .ghostty(GHOSTTY_KEY_ESCAPE)
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x2B,
backend: .inMemory(session),
modifiers: .shift
) == .ghostty(GHOSTTY_KEY_TAB)
)
#expect(
TerminalHardwareKeyRouter.routeUIKit(
usage: 0x2A,
backend: .inMemory(session),
modifiers: .alt
) == .ghostty(GHOSTTY_KEY_BACKSPACE)
)
}
@Test
func `routes app kit arrow keys directly for in memory backends`() {
let session = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x7B,
backend: .inMemory(session)
) == .data(Data("\u{1B}[D".utf8))
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x75,
backend: .inMemory(session)
) == .data(Data("\u{1B}[3~".utf8))
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x30,
backend: .inMemory(session)
) == .data(Data([0x09]))
)
}
@Test
func `routes app kit keys to ghostty for exec backends`() {
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x7B,
backend: .exec
) == .ghostty(GHOSTTY_KEY_ARROW_LEFT)
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x33,
backend: .exec
) == .ghostty(GHOSTTY_KEY_BACKSPACE)
)
}
@Test
func `app kit interpreted commands are replayed as key events`() {
#expect(
TerminalKeyEventHandler.shouldReplayInterpretedCommand(
#selector(NSResponder.insertTab(_:))
)
)
#expect(
TerminalKeyEventHandler.shouldReplayInterpretedCommand(
NSSelectorFromString("insertBacktab:")
)
)
#expect(
TerminalKeyEventHandler.shouldReplayInterpretedCommand(
#selector(NSResponder.moveUp(_:))
)
)
}
/// Quote HID 0x34 must translate to AppKit keycode 0x27, not fall
/// through to `0` (which is AppKit's keycode for the `A` key) nor to
/// `GHOSTTY_KEY_QUOTE.rawValue` (which happens to equal AppKit's
/// keycode for Tab the original bug).
@Test
func `app kit key code for UI kit translates quote to mac keycode`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x34) == 0x27
)
}
@Test
func `app kit key code for UI kit translates common keys`() {
// Letter A: HID 0x04 AppKit 0x00
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x04) == 0x00
)
// Tab: HID 0x2B AppKit 0x30
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x2B) == 0x30
)
// Enter: HID 0x28 AppKit 0x24
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x28) == 0x24
)
// ArrowUp: HID 0x52 AppKit 0x7E
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x52) == 0x7E
)
}
@Test
func `app kit key code for ghostty keys translates common keys`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_A) == 0x00
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_TAB) == 0x30
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_ESCAPE) == 0x35
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_ARROW_LEFT) == 0x7B
)
}
@Test
func `app kit key code for ghostty keys translates higher function and volume keys`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_F17) == 0x40
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_F18) == 0x4F
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_F19) == 0x50
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_F20) == 0x5A
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_AUDIO_VOLUME_UP) == 0x48
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_AUDIO_VOLUME_DOWN) == 0x49
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_AUDIO_VOLUME_MUTE) == 0x4A
)
}
@Test
func `app kit key code for ghostty keys returns sentinel for keys absent from mac`() {
let sentinel = TerminalHardwareKeyRouter.unidentifiedAppKitKeyCode
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_CONTEXT_MENU) == sentinel
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_INSERT) == sentinel
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_CUT) == sentinel
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCode(for: GHOSTTY_KEY_INTL_BACKSLASH) == sentinel
)
}
/// HID usages that have no AppKit counterpart must not collapse to `0`
/// (AppKit's keycode for `A`). They must return the sentinel so
/// libghostty's native-keycode lookup resolves them to `.unidentified`.
@Test
func `app kit key code for UI kit returns sentinel for keys absent from mac`() {
let sentinel = TerminalHardwareKeyRouter.unidentifiedAppKitKeyCode
// CUT, COPY, PASTE, CONTEXT_MENU, INSERT, PRINT_SCREEN, SCROLL_LOCK,
// PAUSE and the higher function keys past F20 are in uiKitMap but
// have no AppKit virtual keycode.
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x7B) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x7C) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x7D) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x65) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x49) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x46) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x47) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x48) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x70) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x71) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x72) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x73) == sentinel)
}
@Test
func `app kit key code for UI kit translates divergent and higher function keys`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x53) == 0x47
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x6C) == 0x40
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x6D) == 0x4F
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x6E) == 0x50
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x6F) == 0x5A
)
}
@Test
func `app kit key code for UI kit translates volume keys`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x7F) == 0x4A
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x80) == 0x48
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x81) == 0x49
)
}
/// The pinned Ghostty keycode table resolves the international backslash
/// HID usage (`0x64`) to AppKit's ISO section keycode (`0x0A`).
@Test
func `app kit key code for UI kit translates intl backslash key`() {
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x64) == 0x0A
)
#expect(
TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x32)
== TerminalHardwareKeyRouter.unidentifiedAppKitKeyCode
)
}
@Test
func `route app kit recognizes higher function and volume keys`() {
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x40,
backend: .exec
) == .ghostty(GHOSTTY_KEY_F17)
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x5A,
backend: .exec
) == .ghostty(GHOSTTY_KEY_F20)
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x48,
backend: .exec
) == .ghostty(GHOSTTY_KEY_AUDIO_VOLUME_UP)
)
#expect(
TerminalHardwareKeyRouter.routeAppKit(
keyCode: 0x4A,
backend: .exec
) == .ghostty(GHOSTTY_KEY_AUDIO_VOLUME_MUTE)
)
}
@Test
func `app kit key code for UI kit returns sentinel for unknown HID`() {
// HID usages not in uiKitMap at all.
let sentinel = TerminalHardwareKeyRouter.unidentifiedAppKitKeyCode
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0xFFFE) == sentinel)
#expect(TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(usage: 0x0001) == sentinel)
}
@Test
func `app kit direct input requires no modifiers`() {
#expect(
TerminalKeyEventHandler.shouldUseDirectInput(
modifierFlags: []
)
)
#expect(
!TerminalKeyEventHandler.shouldUseDirectInput(
modifierFlags: [.shift]
)
)
#expect(
!TerminalKeyEventHandler.shouldUseDirectInput(
modifierFlags: [.control]
)
)
#expect(
!TerminalKeyEventHandler.shouldUseDirectInput(
modifierFlags: [.option]
)
)
#expect(
!TerminalKeyEventHandler.shouldUseDirectInput(
modifierFlags: [.command]
)
)
}
}

View File

@@ -0,0 +1,37 @@
@testable import GhosttyTerminal
import Testing
struct TerminalInputTextTests {
@Test
func `filters apple private use function keys from text path`() {
#expect(TerminalInputText.filteredFunctionKeyText("\u{F702}") == nil)
#expect(TerminalInputText.filteredFunctionKeyText("\u{F703}") == nil)
#expect(TerminalInputText.filteredFunctionKeyText("UIKeyInputLeftArrow") == nil)
#expect(TerminalInputText.filteredFunctionKeyText("UIKeyInputUpArrow") == nil)
#expect(TerminalInputText.filteredFunctionKeyText("a") == "a")
#expect(TerminalInputText.filteredFunctionKeyText("你好") == "你好")
}
@Test
func `recognizes private use function key scalars`() {
#expect(TerminalInputText.isPrivateUseFunctionKey("\u{F702}"))
#expect(TerminalInputText.isPrivateUseFunctionKey("\u{F703}"))
#expect(!TerminalInputText.isPrivateUseFunctionKey("a"))
#expect(!TerminalInputText.isPrivateUseFunctionKey(""))
}
@Test
func `recognizes UI kit named function keys`() {
#expect(TerminalInputText.isUIKitNamedFunctionKey("UIKeyInputLeftArrow"))
#expect(TerminalInputText.isUIKitNamedFunctionKey("UIKeyInputDownArrow"))
#expect(!TerminalInputText.isUIKitNamedFunctionKey("a"))
#expect(!TerminalInputText.isUIKitNamedFunctionKey("你好"))
}
@Test
func `counts paste lines for diagnostics only`() {
#expect(TerminalInputText.lineCount(in: "") == 0)
#expect(TerminalInputText.lineCount(in: "echo ok") == 0)
#expect(TerminalInputText.lineCount(in: "line 1\nline 2\nline 3") == 2)
}
}

View File

@@ -0,0 +1,94 @@
@testable import GhosttyTerminal
import Testing
@MainActor
struct TerminalLifecycleTests {
@Test
func `failed surface creation does not retain bridge`() {
let controller = TerminalController()
let bridge = TerminalCallbackBridge()
let surface = controller.createSurface(
bridge: bridge,
configuration: .init()
) { _ in }
#expect(surface == nil)
#expect(controller.retainedBridgeCount == 0)
}
@Test
func `switching controllers removes bridge from old controller`() {
let oldController = TerminalController()
let newController = TerminalController()
let coordinator = TerminalSurfaceCoordinator()
coordinator.isAttached = { false }
oldController.retain(coordinator.bridge)
#expect(oldController.retainedBridgeCount == 1)
coordinator.controller = oldController
#expect(oldController.retainedBridgeCount == 0)
oldController.retain(coordinator.bridge)
#expect(oldController.retainedBridgeCount == 1)
coordinator.controller = newController
#expect(oldController.retainedBridgeCount == 0)
#expect(newController.retainedBridgeCount == 0)
}
@Test
func `free surface removes retained bridge`() {
let controller = TerminalController()
let coordinator = TerminalSurfaceCoordinator()
coordinator.isAttached = { false }
coordinator.controller = controller
controller.retain(coordinator.bridge)
#expect(controller.retainedBridgeCount == 1)
coordinator.freeSurface()
#expect(controller.retainedBridgeCount == 0)
}
@Test
func `suspended wakeup does not schedule render`() {
let controller = TerminalController()
var wakeups = 0
controller.shouldProcessWakeup = { false }
controller.onWakeup = {
wakeups += 1
}
controller.handleWakeup()
#expect(wakeups == 0)
}
@Test
func `application active state controls immediate ticks`() async {
let coordinator = TerminalSurfaceCoordinator()
var renders = 0
coordinator.isAttached = { true }
coordinator.onPostRender = {
renders += 1
}
coordinator.setApplicationActive(false)
coordinator.requestImmediateTick()
await Task.yield()
#expect(renders == 0)
coordinator.setApplicationActive(true)
await Task.yield()
#expect(renders == 1)
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
@testable import GhosttyTerminal
import Testing
struct TerminalMarkedTextStateTests {
@Test
func `delete backward removes selection before caret`() {
var state = TerminalMarkedTextState()
state.setMarkedText("shufa", selectedRange: NSRange(location: 5, length: 0))
let deleted = state.deleteBackward()
#expect(deleted)
#expect(state.text == "shuf")
#expect(state.selectedRange == NSRange(location: 4, length: 0))
#expect(state.currentSelectedRange == NSRange(location: 4, length: 0))
}
@Test
func `delete backward clears single marked character`() {
var state = TerminalMarkedTextState()
state.setMarkedText("", selectedRange: NSRange(location: 1, length: 0))
let deleted = state.deleteBackward()
#expect(deleted)
#expect(state.text == nil)
#expect(state.markedRange == NSRange(location: NSNotFound, length: 0))
#expect(state.currentSelectedRange == NSRange(location: NSNotFound, length: 0))
}
@Test
func `set marked text clamps selection into document`() {
var state = TerminalMarkedTextState()
state.setMarkedText("abcd", selectedRange: NSRange(location: 99, length: 3))
#expect(state.selectedRange == NSRange(location: 4, length: 0))
#expect(state.documentLength == 4)
}
@Test
func `text in range returns substring and empty caret slice`() {
var state = TerminalMarkedTextState()
state.setMarkedText("中文abc", selectedRange: NSRange(location: 2, length: 0))
#expect(state.text(in: NSRange(location: 0, length: 2)) == "中文")
#expect(state.text(in: NSRange(location: 2, length: 0)) == "")
#expect(state.text(in: NSRange(location: 99, length: 1)) == nil)
}
}

View File

@@ -0,0 +1,25 @@
@testable import GhosttyTerminal
import Testing
@MainActor
struct TerminalNavigationAPITests {
@Test
func `view state navigation requires an attached surface`() {
let state = TerminalViewState()
#expect(!state.performBindingAction("scroll_to_top"))
#expect(!state.jumpToPrompt(by: -1))
#expect(!state.scrollToRow(0))
}
@Test
func `platform view exposes navigation actions`() {
let invokeActions: (TerminalView) -> Void = { view in
_ = view.performBindingAction("scroll_to_top")
_ = view.jumpToPrompt(by: 1)
_ = view.scrollToRow(42)
}
_ = invokeActions
}
}

View File

@@ -0,0 +1,221 @@
import Foundation
@testable import GhosttyTerminal
import Testing
// All test cases use host-point units. cellWidthPoints = 10, cellHeightPoints = 20
// unless otherwise noted. The function's contract is "pointX / cellWidthPoints
// = expected cell column" that identity holds regardless of physical device
// scale, so callers passing host points consistently get correct results on
// @1x/2x/3x devices alike. A caller passing surface pixels without dividing
// by displayScale would compute the wrong expectedColumn that's a caller
// bug, not a function bug. See plan §1 and §10 for the unit contract.
struct TerminalSelectionAnchorTests {
@Test
func `single line ASCII`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "hello world",
word: "world",
pointX: 60, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 6, length: 5))
}
@Test
func `multi line locates row`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "aaa\nbbb\nworld",
word: "world",
pointX: 0, pointY: 40,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 8, length: 5))
}
@Test
func `same word across rows only picks target row`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "foo\nfoo\nfoo",
word: "foo",
pointX: 0, pointY: 20,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 4, length: 3))
}
@Test
func `empty rows preserved`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "\n\nhello",
word: "hello",
pointX: 0, pointY: 40,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 2, length: 5))
}
@Test
func `word not found returns nil`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "abc",
word: "xyz",
pointX: 0, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == nil)
}
@Test
func `row out of bounds returns nil`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "abc",
word: "abc",
pointX: 0, pointY: 100,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == nil)
}
@Test
func `emoji surrogate pair`() {
let text = "hi 👋"
let range = TerminalSelectionAnchor.resolveRange(
in: text,
word: "👋",
pointX: 30, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
let nsText = text as NSString
#expect(range != nil)
if let r = range {
#expect(r == NSRange(location: 3, length: 2))
#expect(NSMaxRange(r) <= nsText.length)
}
}
@Test
func `cjk full width`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "你好 world",
word: "你好",
pointX: 0, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 0, length: 2))
}
@Test
func `zero cell dimensions`() {
let r1 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: 0, pointY: 0,
cellWidthPoints: 0, cellHeightPoints: 20
)
let r2 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: 0, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 0
)
#expect(r1 == nil)
#expect(r2 == nil)
}
@Test
func `empty word`() {
let range = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "",
pointX: 0, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == nil)
}
@Test
func `negative coordinates`() {
let r1 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: -1, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
let r2 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: 0, pointY: -1,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(r1 == nil)
#expect(r2 == nil)
}
@Test
func `substring disambiguation by point X`() {
// `catalog cat` long-pressed at the end `cat` (column 8) must pick
// the standalone `cat` at location 8, not the prefix inside `catalog`
// at location 0. literals at {0, 8}, expectedColumn=8 8.
let range = TerminalSelectionAnchor.resolveRange(
in: "catalog cat",
word: "cat",
pointX: 80, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 8, length: 3))
}
@Test
func `triple repeat picked by point X`() {
// literals at {0, 4, 8}, expectedColumn=8 8.
let range = TerminalSelectionAnchor.resolveRange(
in: "cat cat cat",
word: "cat",
pointX: 80, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 8, length: 3))
}
@Test
func `non word characters in token`() {
// literals at {4, 15}, expectedColumn=15 15.
let range = TerminalSelectionAnchor.resolveRange(
in: "see /usr/local /usr/local",
word: "/usr/local",
pointX: 150, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 15, length: 10))
}
@Test
func `non word prefix token`() {
// literals at {1, 6}, expectedColumn=6 6.
let range = TerminalSelectionAnchor.resolveRange(
in: "x/foo /foo",
word: "/foo",
pointX: 60, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
#expect(range == NSRange(location: 6, length: 4))
}
@Test
func `nan and infinity guarded`() {
let r1 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: .nan, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
let r2 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: .infinity, pointY: 0,
cellWidthPoints: 10, cellHeightPoints: 20
)
let r3 = TerminalSelectionAnchor.resolveRange(
in: "abc", word: "abc",
pointX: 0, pointY: 0,
cellWidthPoints: .nan, cellHeightPoints: 20
)
#expect(r1 == nil)
#expect(r2 == nil)
#expect(r3 == nil)
}
}

View File

@@ -0,0 +1,23 @@
@testable import GhosttyTerminal
import Testing
struct TerminalSurfaceOptionsTests {
@Test
func `envVars default to empty`() {
#expect(TerminalSurfaceOptions().envVars.isEmpty)
}
@Test
func `isEquivalent considers envVars`() {
let base = TerminalSurfaceOptions(workingDirectory: "/tmp", envVars: ["A": "1"])
#expect(base.isEquivalent(to: base))
var changedValue = base
changedValue.envVars = ["A": "2"]
#expect(!base.isEquivalent(to: changedValue))
var extraEntry = base
extraEntry.envVars = ["A": "1", "B": "2"]
#expect(!base.isEquivalent(to: extraEntry))
}
}

View File

@@ -0,0 +1,44 @@
@testable import GhosttyTerminal
import SwiftUI
import Testing
@Suite("TerminalSurfaceViewFocusAPI")
struct TerminalSurfaceViewFocusAPITests {
@Test
@MainActor
func `bool focus modifier compiles`() {
_ = BoolFocusSmokeView()
}
@Test
@MainActor
func `optional focus modifier compiles`() {
_ = OptionalFocusSmokeView()
}
}
@available(macOS 13.0, iOS 15.0, macCatalyst 15.0, *)
private struct BoolFocusSmokeView: View {
@StateObject private var state = TerminalViewState()
@FocusState private var isFocused: Bool
var body: some View {
TerminalSurfaceView(context: state)
.terminalFocused($isFocused)
}
}
@available(macOS 13.0, iOS 15.0, macCatalyst 15.0, *)
private struct OptionalFocusSmokeView: View {
enum Pane: Hashable {
case primary
}
@StateObject private var state = TerminalViewState()
@FocusState private var focusedPane: Pane?
var body: some View {
TerminalSurfaceView(context: state)
.terminalFocusOnAppear($focusedPane, equals: .primary)
}
}

View File

@@ -0,0 +1,220 @@
@testable import GhosttyTerminal
import Combine
import SwiftUI
import Testing
@MainActor
struct TerminalThemeConfigurationTests {
@Test
func `command builder preserves insertion order`() {
let configuration = TerminalConfiguration {
$0.withFontSize(13)
$0.withCursorStyle(.bar)
$0.withCursorStyleBlink(false)
$0.withBackground("#101010")
}
#expect(
configuration.rendered
== """
font-size = 13
cursor-style = bar
cursor-style-blink = false
background = #101010
"""
)
}
@Test
func `rendered config composes base behavior and theme`() {
let state = TerminalViewState(
configSource: .generated(
"""
font-size = 14
cursor-style = block
"""
),
theme: .init(
light: TerminalConfiguration()
.backgroundOpacity(0.82)
.background("#111111"),
dark: TerminalConfiguration()
.backgroundOpacity(0.62)
.background("#000000")
),
terminalConfiguration: TerminalConfiguration()
.cursorStyleBlink(true)
)
#expect(state.renderedConfig.contains("font-size = 14"))
#expect(state.renderedConfig.contains("cursor-style = block"))
#expect(state.renderedConfig.contains("cursor-style-blink = true"))
#expect(state.renderedConfig.contains("background-opacity = 0.82"))
#expect(state.renderedConfig.contains("background = #111111"))
}
@Test
func `valid configuration update preserves controller identity`() {
let state = TerminalViewState(
terminalConfiguration: TerminalConfiguration()
.fontSize(14)
)
let controller = state.controller
let didApply = state.setTerminalConfiguration(
TerminalConfiguration()
.fontSize(16)
.cursorStyle(.underline)
)
#expect(didApply)
#expect(state.controller === controller)
#expect(state.terminalConfiguration == TerminalConfiguration()
.fontSize(16)
.cursorStyle(.underline))
#expect(state.renderedConfig.contains("font-size = 16"))
#expect(state.renderedConfig.contains("cursor-style = underline"))
let fontSizeLines = state.renderedConfig
.split(separator: "\n")
.filter { $0.hasPrefix("font-size = ") }
#expect(fontSizeLines.last == "font-size = 16")
}
@Test
func `adopting dark mode switches rendered theme variant`() {
let state = TerminalViewState(
theme: .init(
light: TerminalConfiguration()
.backgroundOpacity(0.91),
dark: TerminalConfiguration()
.backgroundOpacity(0.47)
)
)
var notificationCount = 0
var colorSchemeAtNotification: TerminalColorScheme?
let cancellable = state.objectWillChange.sink {
notificationCount += 1
colorSchemeAtNotification = state.effectiveColorScheme
}
defer { cancellable.cancel() }
let controller = state.controller
#expect(state.effectiveColorScheme == .light)
#expect(state.renderedConfig.contains("background-opacity = 0.91"))
state.adopt(colorScheme: .dark)
#expect(notificationCount == 1)
#expect(colorSchemeAtNotification == .light)
#expect(state.effectiveColorScheme == .dark)
#expect(state.renderedConfig.contains("background-opacity = 0.47"))
#expect(!state.renderedConfig.contains("background-opacity = 0.91"))
#expect(state.controller === controller)
}
@Test
func `invalid dark theme rolls back color scheme and rendered config`() {
let state = TerminalViewState(
theme: .init(
light: TerminalConfiguration()
.backgroundOpacity(0.91),
dark: TerminalConfiguration()
.custom("not-a-real-ghostty-option", "true")
)
)
var notificationCount = 0
let cancellable = state.objectWillChange.sink {
notificationCount += 1
}
defer { cancellable.cancel() }
let previousRenderedConfig = state.renderedConfig
state.adopt(colorScheme: .dark)
// Controller rolls back on config failure, so color scheme stays light
#expect(notificationCount == 0)
#expect(state.effectiveColorScheme == .light)
#expect(state.renderedConfig == previousRenderedConfig)
#expect(state.renderedConfig.contains("background-opacity = 0.91"))
}
@Test
func `adopting current color scheme does not notify`() {
let state = TerminalViewState()
var notificationCount = 0
let cancellable = state.objectWillChange.sink {
notificationCount += 1
}
defer { cancellable.cancel() }
#expect(state.effectiveColorScheme == .light)
state.adopt(colorScheme: .light)
#expect(notificationCount == 0)
#expect(state.effectiveColorScheme == .light)
}
@Test
func `shared controller accepts mutations`() {
let controller = TerminalController()
let state = TerminalViewState(controller: controller)
// With the single-source-of-truth architecture, mutations go
// directly to the controller and succeed.
let didSetTheme = state.setTheme(
.init(
light: TerminalConfiguration()
.backgroundOpacity(0.5)
)
)
#expect(didSetTheme)
#expect(state.controller === controller)
#expect(state.renderedConfig.contains("background-opacity = 0.5"))
}
@Test
func `no op theme update returns false`() {
let theme = TerminalTheme(
light: TerminalConfiguration()
.backgroundOpacity(0.7)
)
let state = TerminalViewState(theme: theme)
var notificationCount = 0
let cancellable = state.objectWillChange.sink {
notificationCount += 1
}
defer { cancellable.cancel() }
let previousRenderedConfig = state.renderedConfig
let didApply = state.setTheme(theme)
#expect(!didApply)
#expect(notificationCount == 0)
#expect(state.renderedConfig == previousRenderedConfig)
}
@Test
func `invalid configuration does not replace rendered config`() {
let state = TerminalViewState(
terminalConfiguration: TerminalConfiguration()
.fontSize(14)
)
var notificationCount = 0
let cancellable = state.objectWillChange.sink {
notificationCount += 1
}
defer { cancellable.cancel() }
let previousRenderedConfig = state.renderedConfig
let didApply = state.setTerminalConfiguration(
TerminalConfiguration()
.custom("not-a-real-ghostty-option", "true")
)
#expect(!didApply)
#expect(notificationCount == 0)
#expect(state.renderedConfig == previousRenderedConfig)
#expect(state.terminalConfiguration == TerminalConfiguration().fontSize(14))
}
}

View File

@@ -0,0 +1,31 @@
import GhosttyKit
import XCTest
class GhosttyKitTest: XCTestCase {
func testAppLifecycle() {
XCTAssertEqual(ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv), GHOSTTY_SUCCESS)
guard let config = ghostty_config_new() else {
return XCTFail("ghostty_config_new returned nil")
}
defer { ghostty_config_free(config) }
ghostty_config_finalize(config)
var runtime = ghostty_runtime_config_s(
userdata: nil,
supports_selection_clipboard: false,
wakeup_cb: nil,
action_cb: nil,
read_clipboard_cb: nil,
confirm_read_clipboard_cb: nil,
write_clipboard_cb: nil,
close_surface_cb: nil
)
guard let app = ghostty_app_new(&runtime, config) else {
return XCTFail("ghostty_app_new returned nil")
}
ghostty_app_free(app)
}
}