初始提交: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:
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
128
packages/TXCore/Tests/TXCoreTests/SessionMachineTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("会话生命周期状态机")
|
||||
struct SessionMachineTests {
|
||||
|
||||
@Test("正常连接流程")
|
||||
func happyPath() {
|
||||
var m = SessionMachine()
|
||||
#expect(m.reduce(.connectRequested) == [.startConnect])
|
||||
#expect(m.phase == .connecting)
|
||||
#expect(m.reduce(.authenticating) == [])
|
||||
#expect(m.phase == .authenticating)
|
||||
#expect(m.reduce(.established) == [.notify("已连接")])
|
||||
#expect(m.phase == .connected)
|
||||
}
|
||||
|
||||
@Test("认证失败为终态,不自动重连")
|
||||
func authFailedTerminal() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
let effects = m.reduce(.authFailed(reason: "密码错误"))
|
||||
#expect(m.phase == .failed(reason: "密码错误"))
|
||||
#expect(effects.contains(.teardownTransport))
|
||||
// failed 态收到网络事件不动
|
||||
#expect(m.reduce(.transportClosed(reason: nil)) == [])
|
||||
#expect(m.phase == .failed(reason: "密码错误"))
|
||||
}
|
||||
|
||||
@Test("断线进入退避重连")
|
||||
func disconnectSchedulesReconnect() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
let effects = m.reduce(.transportClosed(reason: "eof"))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
#expect(effects.contains(.teardownTransport))
|
||||
#expect(effects.contains(.scheduleReconnect(attempt: 1, delayMS: 500)))
|
||||
}
|
||||
|
||||
@Test("重连计时→重连→成功")
|
||||
func reconnectSuccess() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.reduce(.reconnectTimerFired) == [.startConnect])
|
||||
#expect(m.phase == .reconnecting(attempt: 1))
|
||||
#expect(m.reduce(.established) == [.notify("已重连")])
|
||||
#expect(m.phase == .connected)
|
||||
}
|
||||
|
||||
@Test("重连反复失败,退避递增,达上限转 failed")
|
||||
func reconnectExhaustion() {
|
||||
var m = SessionMachine(maxReconnectAttempts: 3, baseDelayMS: 500, maxDelayMS: 16000)
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
|
||||
// 第1次断
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(1)
|
||||
// 第2次断(attempt 2, delay 1000)
|
||||
let e2 = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 2))
|
||||
#expect(e2.contains(.scheduleReconnect(attempt: 2, delayMS: 1000)))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(2)
|
||||
// 第3次断(attempt 3, delay 2000)
|
||||
let e3 = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 3))
|
||||
#expect(e3.contains(.scheduleReconnect(attempt: 3, delayMS: 2000)))
|
||||
_ = m.reduce(.reconnectTimerFired) // reconnecting(3)
|
||||
// 第4次断 → 超过 maxReconnectAttempts(3) → failed
|
||||
let e4 = m.reduce(.transportClosed(reason: "gone"))
|
||||
if case .failed = m.phase {} else { Issue.record("期望 failed,实际 \(m.phase)") }
|
||||
#expect(e4.contains(.notify("重连失败,请手动重试")))
|
||||
}
|
||||
|
||||
@Test("退避封顶")
|
||||
func backoffCap() {
|
||||
let m = SessionMachine(baseDelayMS: 500, maxDelayMS: 16000)
|
||||
#expect(m.backoffDelayMS(attempt: 1) == 500)
|
||||
#expect(m.backoffDelayMS(attempt: 2) == 1000)
|
||||
#expect(m.backoffDelayMS(attempt: 5) == 8000)
|
||||
#expect(m.backoffDelayMS(attempt: 6) == 16000)
|
||||
#expect(m.backoffDelayMS(attempt: 10) == 16000) // 封顶
|
||||
}
|
||||
|
||||
@Test("后台冻结与前台恢复")
|
||||
func backgroundResume() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
#expect(m.reduce(.enteredBackground) == [])
|
||||
#expect(m.phase == .backgroundParked)
|
||||
let effects = m.reduce(.enteredForeground)
|
||||
#expect(m.phase == .reconnecting(attempt: 1))
|
||||
#expect(effects.contains(.startConnect))
|
||||
}
|
||||
|
||||
@Test("等待重连时进入后台取消计时")
|
||||
func backgroundDuringWait() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
_ = m.reduce(.transportClosed(reason: nil))
|
||||
#expect(m.phase == .waitingToReconnect(attempt: 1))
|
||||
#expect(m.reduce(.enteredBackground) == [.cancelReconnectTimer])
|
||||
#expect(m.phase == .backgroundParked)
|
||||
}
|
||||
|
||||
@Test("主动关闭与手动重试复位")
|
||||
func closeAndRetry() {
|
||||
var m = SessionMachine()
|
||||
_ = m.reduce(.connectRequested)
|
||||
_ = m.reduce(.established)
|
||||
let closeEffects = m.reduce(.closeRequested)
|
||||
#expect(m.phase == .closed)
|
||||
#expect(closeEffects.contains(.teardownTransport))
|
||||
|
||||
// failed 后手动重试
|
||||
var m2 = SessionMachine()
|
||||
_ = m2.reduce(.connectRequested)
|
||||
_ = m2.reduce(.authFailed(reason: "x"))
|
||||
#expect(m2.reduce(.retryRequested) == [.startConnect])
|
||||
#expect(m2.phase == .connecting)
|
||||
}
|
||||
}
|
||||
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal file
118
packages/TXCore/Tests/TXCoreTests/TmuxControlParserTests.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux control-mode 解析器")
|
||||
struct TmuxControlParserTests {
|
||||
|
||||
@Test("命令应答块按序号关联")
|
||||
func commandBlock() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%begin 1678 7 1\nhello\nworld\n%end 1678 7 1\n")
|
||||
#expect(events.count == 1)
|
||||
guard case .commandResponse(let r) = events[0] else {
|
||||
Issue.record("期望 commandResponse"); return
|
||||
}
|
||||
#expect(r.number == 7)
|
||||
#expect(r.isError == false)
|
||||
#expect(r.lines == ["hello", "world"])
|
||||
}
|
||||
|
||||
@Test("%error 块标记为错误")
|
||||
func errorBlock() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%begin 1 3 1\nno server\n%error 1 3 1\n")
|
||||
guard case .commandResponse(let r) = events.first else {
|
||||
Issue.record("期望 commandResponse"); return
|
||||
}
|
||||
#expect(r.isError)
|
||||
#expect(r.number == 3)
|
||||
}
|
||||
|
||||
@Test("块内以 % 开头的行不解释为通知")
|
||||
func percentInsideBlock() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%begin 1 1 1\n%output not-a-notification\n%end 1 1 1\n")
|
||||
#expect(events.count == 1)
|
||||
guard case .commandResponse(let r) = events.first else {
|
||||
Issue.record("期望 commandResponse"); return
|
||||
}
|
||||
#expect(r.lines == ["%output not-a-notification"])
|
||||
}
|
||||
|
||||
@Test("%output 解析出 pane 与字节")
|
||||
func output() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%output %2 ready\\012\n")
|
||||
guard case .output(let pane, let data) = events.first else {
|
||||
Issue.record("期望 output"); return
|
||||
}
|
||||
#expect(pane == TmuxPaneID(2))
|
||||
#expect(data == Data("ready\n".utf8))
|
||||
}
|
||||
|
||||
@Test("跨 feed 的残行被正确缓冲")
|
||||
func splitAcrossFeeds() {
|
||||
let p = TmuxControlParser()
|
||||
#expect(p.feed("%output %0 par").isEmpty) // 无换行 → 无事件
|
||||
let events = p.feed("tial\n")
|
||||
guard case .output(_, let data) = events.first else {
|
||||
Issue.record("期望 output"); return
|
||||
}
|
||||
#expect(data == Data("partial".utf8))
|
||||
}
|
||||
|
||||
@Test("CRLF 行尾被容忍")
|
||||
func crlf() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%window-add @5\r\n")
|
||||
#expect(events == [.windowAdd(TmuxWindowID(5))])
|
||||
}
|
||||
|
||||
@Test("窗口生命周期通知")
|
||||
func windowNotifications() {
|
||||
let p = TmuxControlParser()
|
||||
#expect(p.feed("%window-add @1\n") == [.windowAdd(TmuxWindowID(1))])
|
||||
#expect(p.feed("%window-close @1\n") == [.windowClose(TmuxWindowID(1))])
|
||||
#expect(p.feed("%window-renamed @2 my shell\n")
|
||||
== [.windowRenamed(TmuxWindowID(2), name: "my shell")])
|
||||
}
|
||||
|
||||
@Test("会话与布局通知")
|
||||
func sessionAndLayout() {
|
||||
let p = TmuxControlParser()
|
||||
#expect(p.feed("%session-changed $1 main\n")
|
||||
== [.sessionChanged(TmuxSessionID(1), name: "main")])
|
||||
let layout = p.feed("%layout-change @3 bc62,80x24,0,0,0\n")
|
||||
#expect(layout == [.layoutChange(window: TmuxWindowID(3),
|
||||
layout: "bc62,80x24,0,0,0",
|
||||
visibleLayout: nil)])
|
||||
}
|
||||
|
||||
@Test("流控与退出")
|
||||
func flowControlAndExit() {
|
||||
let p = TmuxControlParser()
|
||||
#expect(p.feed("%pause %4\n") == [.pause(TmuxPaneID(4))])
|
||||
#expect(p.feed("%continue %4\n") == [.unpause(TmuxPaneID(4))])
|
||||
#expect(p.feed("%exit\n") == [.exit(reason: nil)])
|
||||
#expect(p.feed("%exit server exited\n") == [.exit(reason: "server exited")])
|
||||
}
|
||||
|
||||
@Test("未知通知降级为 .unknown 而非崩溃")
|
||||
func unknownNotification() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%some-future-notification foo bar\n")
|
||||
#expect(events == [.unknown(line: "%some-future-notification foo bar")])
|
||||
}
|
||||
|
||||
@Test("attach 后的隐式空块")
|
||||
func implicitEmptyBlock() {
|
||||
let p = TmuxControlParser()
|
||||
let events = p.feed("%begin 100 0 0\n%end 100 0 0\n")
|
||||
guard case .commandResponse(let r) = events.first else {
|
||||
Issue.record("期望空 commandResponse"); return
|
||||
}
|
||||
#expect(r.lines.isEmpty)
|
||||
#expect(r.number == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Testing
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux -CC 哨兵序列检测")
|
||||
struct TmuxControlSequenceTests {
|
||||
|
||||
@Test("查找进入 DCS")
|
||||
func findEnter() {
|
||||
let stream = Array("shell output\r\n".utf8) + TmuxControlSequence.enter + Array("%begin".utf8)
|
||||
let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: stream)
|
||||
#expect(idx == Array("shell output\r\n".utf8).count)
|
||||
}
|
||||
|
||||
@Test("查找退出 ST")
|
||||
func findExit() {
|
||||
let stream = Array("%exit\n".utf8) + TmuxControlSequence.exit
|
||||
#expect(TmuxControlSequence.find(TmuxControlSequence.exit, in: stream) == Array("%exit\n".utf8).count)
|
||||
}
|
||||
|
||||
@Test("未出现返回 nil")
|
||||
func notFound() {
|
||||
#expect(TmuxControlSequence.find(TmuxControlSequence.enter, in: Array("normal text".utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test("enter 序列即 ESC P 1000 p")
|
||||
func enterBytes() {
|
||||
#expect(TmuxControlSequence.enter == [0x1B, 0x50, 0x31, 0x30, 0x30, 0x30, 0x70])
|
||||
}
|
||||
}
|
||||
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal file
59
packages/TXCore/Tests/TXCoreTests/TmuxLayoutTests.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Testing
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux layout 字符串解析")
|
||||
struct TmuxLayoutTests {
|
||||
|
||||
@Test("单 pane")
|
||||
func single() throws {
|
||||
let l = try TmuxLayout.parse("bc62,80x24,0,0,0")
|
||||
#expect(l.root == .leaf(pane: TmuxPaneID(0),
|
||||
rect: .init(width: 80, height: 24, x: 0, y: 0)))
|
||||
#expect(l.paneIDs == [TmuxPaneID(0)])
|
||||
}
|
||||
|
||||
@Test("左右分屏 {} → horizontal")
|
||||
func horizontalSplit() throws {
|
||||
let l = try TmuxLayout.parse("a1b2,80x24,0,0{40x24,0,0,1,39x24,41,0,2}")
|
||||
guard case .horizontal(let children, let rect) = l.root else {
|
||||
Issue.record("期望 horizontal"); return
|
||||
}
|
||||
#expect(rect == .init(width: 80, height: 24, x: 0, y: 0))
|
||||
#expect(children.count == 2)
|
||||
#expect(children[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 40, height: 24, x: 0, y: 0)))
|
||||
#expect(children[1] == .leaf(pane: TmuxPaneID(2), rect: .init(width: 39, height: 24, x: 41, y: 0)))
|
||||
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
|
||||
}
|
||||
|
||||
@Test("上下分屏 [] → vertical")
|
||||
func verticalSplit() throws {
|
||||
let l = try TmuxLayout.parse("ffff,80x24,0,0[80x12,0,0,1,80x11,0,13,2]")
|
||||
guard case .vertical(let children, _) = l.root else {
|
||||
Issue.record("期望 vertical"); return
|
||||
}
|
||||
#expect(children.count == 2)
|
||||
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2)])
|
||||
}
|
||||
|
||||
@Test("嵌套:左右分屏,右侧再上下分屏")
|
||||
func nested() throws {
|
||||
let l = try TmuxLayout.parse("0000,100x40,0,0{50x40,0,0,1,49x40,51,0[49x20,51,0,2,49x19,51,21,3]}")
|
||||
guard case .horizontal(let top, _) = l.root else {
|
||||
Issue.record("期望 horizontal"); return
|
||||
}
|
||||
#expect(top.count == 2)
|
||||
#expect(top[0] == .leaf(pane: TmuxPaneID(1), rect: .init(width: 50, height: 40, x: 0, y: 0)))
|
||||
guard case .vertical(let inner, _) = top[1] else {
|
||||
Issue.record("期望嵌套 vertical"); return
|
||||
}
|
||||
#expect(inner.count == 2)
|
||||
#expect(l.paneIDs == [TmuxPaneID(1), TmuxPaneID(2), TmuxPaneID(3)])
|
||||
}
|
||||
|
||||
@Test("畸形字符串抛错而非崩溃")
|
||||
func malformed() {
|
||||
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("garbage") }
|
||||
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x24,0,0{") }
|
||||
#expect(throws: TmuxLayout.ParseError.self) { try TmuxLayout.parse("abcd,80x,0,0,0") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import TXCore
|
||||
|
||||
@Suite("tmux %output 八进制解码")
|
||||
struct TmuxOutputDecoderTests {
|
||||
|
||||
// 通过公开 API 间接验证 decode(decoder 为 internal)。
|
||||
private func decode(_ escaped: String) -> Data {
|
||||
let parser = TmuxControlParser()
|
||||
let events = parser.feed("%output %0 \(escaped)\n")
|
||||
guard case .output(_, let data) = events.first else {
|
||||
Issue.record("期望 .output,得到 \(events)")
|
||||
return Data()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@Test("普通字符原样通过")
|
||||
func plain() {
|
||||
#expect(decode("hello") == Data("hello".utf8))
|
||||
}
|
||||
|
||||
@Test("空格不转义,原样保留")
|
||||
func rawSpace() {
|
||||
#expect(decode("hello\\040world") == Data("hello world".utf8))
|
||||
// payload 中的真实空格也应保留
|
||||
#expect(decode("a b") == Data("a b".utf8))
|
||||
}
|
||||
|
||||
@Test("控制字符八进制还原")
|
||||
func controlBytes() {
|
||||
#expect(decode("line\\015\\012") == Data([0x6C, 0x69, 0x6E, 0x65, 0x0D, 0x0A]))
|
||||
}
|
||||
|
||||
@Test("反斜杠自身 \\134")
|
||||
func backslash() {
|
||||
#expect(decode("a\\134b") == Data([0x61, 0x5C, 0x62]))
|
||||
}
|
||||
|
||||
@Test("不完整转义序列原样保留")
|
||||
func malformedEscape() {
|
||||
// 末尾孤立反斜杠 + 不足三位
|
||||
#expect(decode("x\\12") == Data("x\\12".utf8))
|
||||
}
|
||||
|
||||
@Test("非八进制数字(8/9)不视为转义")
|
||||
func nonOctalDigits() {
|
||||
#expect(decode("\\189") == Data("\\189".utf8))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user