- 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>
447 lines
14 KiB
Swift
447 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|