初始提交: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:
449
vendor/libghostty-spm/Example/MobileGhosttyAppUITests/MobileGhosttyAppUITests.swift
vendored
Normal file
449
vendor/libghostty-spm/Example/MobileGhosttyAppUITests/MobileGhosttyAppUITests.swift
vendored
Normal file
@@ -0,0 +1,449 @@
|
||||
import XCTest
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MobileGhosttyAppUITests: XCTestCase {
|
||||
private var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--ui-testing"]
|
||||
installSystemAlertHandler()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
XCUIDevice.shared.orientation = .landscapeLeft
|
||||
}
|
||||
#endif
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
capture("final-state")
|
||||
app = nil
|
||||
}
|
||||
|
||||
func testTerminalUserOperations() throws {
|
||||
let terminal = try requireTerminalInteractionTarget()
|
||||
|
||||
capture("01-launch")
|
||||
typeTerminalText("echo ui-single\n", in: terminal)
|
||||
capture("02-single-line-input")
|
||||
|
||||
typeTerminalText("echo first line\n", in: terminal)
|
||||
typeTerminalText("echo second line\n", in: terminal)
|
||||
capture("03-multiple-lines")
|
||||
|
||||
typeTerminalText("中文键盘测试,标点和全角字符。\n", in: terminal)
|
||||
capture("04-chinese-input")
|
||||
|
||||
typeTerminalText("日本語キーボードテスト、かなと漢字。\n", in: terminal)
|
||||
capture("05-japanese-input")
|
||||
|
||||
typeTerminalText("Mixed input: English 中文 日本語 123\n", in: terminal)
|
||||
capture("06-multilingual-input")
|
||||
|
||||
tapTerminal(in: terminal)
|
||||
capture("07-tap-dismiss-keyboard")
|
||||
tapTerminal(in: terminal)
|
||||
capture("08-tap-refocus")
|
||||
|
||||
typeTerminalText("help\n", in: terminal)
|
||||
capture("09-help-output")
|
||||
|
||||
terminal.swipeUp()
|
||||
capture("10-swipe-up")
|
||||
terminal.swipeDown()
|
||||
capture("11-swipe-down")
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.typeKey("=", modifierFlags: .command)
|
||||
#else
|
||||
terminal.pinch(withScale: 1.25, velocity: 1.0)
|
||||
#endif
|
||||
capture("12-zoom-in")
|
||||
// Use the keyboard zoom-out path on iOS because XCTest's second
|
||||
// pinch in one test session can report an invalid coordinate.
|
||||
app.typeKey("-", modifierFlags: .command)
|
||||
capture("13-zoom-out")
|
||||
|
||||
typeTerminalText("clear\n", in: terminal)
|
||||
capture("14-clear-command")
|
||||
|
||||
let pointerSelectionCommand: String
|
||||
#if targetEnvironment(macCatalyst)
|
||||
pointerSelectionCommand = "echo \(catalystPointerSelectionPrefix)\(expectedPointerSelection)\n"
|
||||
#else
|
||||
pointerSelectionCommand = isIPad
|
||||
? "echo \(iPadPointerSelectionPrefix)\(expectedPointerSelection)\n"
|
||||
: "echo \(expectedPointerSelection)\n"
|
||||
#endif
|
||||
typeTerminalText(pointerSelectionCommand, in: terminal)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
dragPointerSelection(in: terminal)
|
||||
capture("15-pointer-selection-catalyst")
|
||||
openCopyMenuAndCopySelection(in: terminal, screenshotName: "16-pointer-copy-menu-catalyst")
|
||||
longPressTerminal(in: terminal)
|
||||
capture("17-long-press-catalyst")
|
||||
#else
|
||||
if isIPad {
|
||||
longPressTerminal(in: terminal, offset: CGVector(dx: 0.35, dy: 0.18))
|
||||
XCTAssertTrue(selectionTextView().waitForExistence(timeout: 4))
|
||||
capture("15-long-press-selection")
|
||||
dismissSelectionSheet()
|
||||
|
||||
hideSoftwareKeyboardIfVisible()
|
||||
capture("16-ipad-keyboard-hidden-before-pointer")
|
||||
dragIPadPointerSelection(in: terminal)
|
||||
capture("17-ipad-pointer-selection")
|
||||
openCopyMenuAndCopySelection(
|
||||
in: terminal,
|
||||
screenshotName: "18-ipad-pointer-copy-menu",
|
||||
rightClickOffset: CGVector(dx: 0.10, dy: 0.035)
|
||||
)
|
||||
} else {
|
||||
longPressTerminal(in: terminal, offset: CGVector(dx: 0.35, dy: 0.18))
|
||||
XCTAssertTrue(selectionTextView().waitForExistence(timeout: 4))
|
||||
capture("15-long-press-selection")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if isIPad {
|
||||
tapTerminal(in: terminal)
|
||||
tapAccessoryButton("Tab", screenshotName: "16-accessory-tab")
|
||||
tapAccessoryButton("Esc", screenshotName: "17-accessory-esc")
|
||||
tapAccessoryButton("Right", screenshotName: "18-accessory-right")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
openThemeMenuAndSelectPopularTheme()
|
||||
capture("19-theme-menu-selection")
|
||||
#else
|
||||
if isIPad {
|
||||
openThemeMenuAndSelectPopularTheme()
|
||||
capture("19-theme-menu-selection")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func installSystemAlertHandler() {
|
||||
addUIInterruptionMonitor(withDescription: "System alert") { alert in
|
||||
let preferredButtons = [
|
||||
"OK", "Ok", "好", "确定", "允许", "Allow", "继续", "Continue",
|
||||
"关闭", "Close", "Dismiss",
|
||||
]
|
||||
for title in preferredButtons {
|
||||
let button = alert.buttons[title].firstMatch
|
||||
if button.exists {
|
||||
self.activateInterruptionButton(button)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let firstButton = alert.buttons.firstMatch
|
||||
guard firstButton.exists else { return false }
|
||||
if firstButton.identifier == "InputSource" ||
|
||||
firstButton.label.hasPrefix("com.apple.inputmethod.")
|
||||
{
|
||||
self.app.typeKey(.escape, modifierFlags: [])
|
||||
return true
|
||||
}
|
||||
self.activateInterruptionButton(firstButton)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func activateInterruptionButton(_ button: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
button.click()
|
||||
#else
|
||||
button.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func requireTerminalInteractionTarget() throws -> XCUIElement {
|
||||
let terminal = app.descendants(matching: .any)["terminal.surface"].firstMatch
|
||||
if terminal.waitForExistence(timeout: 4), terminal.isHittable {
|
||||
return terminal
|
||||
}
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(window.isHittable)
|
||||
return window
|
||||
}
|
||||
|
||||
private func tapTerminal(in element: XCUIElement) {
|
||||
let coordinate = element.coordinate(withNormalizedOffset: terminalInteractionOffset)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
coordinate.click()
|
||||
#else
|
||||
if isIPad {
|
||||
coordinate.tap()
|
||||
} else {
|
||||
coordinate.press(forDuration: 0.01)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func typeTerminalText(_ text: String, in element: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
element.coordinate(withNormalizedOffset: terminalInteractionOffset).click()
|
||||
app.typeText(text)
|
||||
#else
|
||||
if !isIPad, !prepareTerminalForTyping(element) {
|
||||
return
|
||||
}
|
||||
element.typeText(text)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
private func prepareTerminalForTyping(_ element: XCUIElement) -> Bool {
|
||||
if selectionTextView().exists {
|
||||
dismissSelectionSheet()
|
||||
}
|
||||
if waitForKeyboardFocus(in: element, timeout: 0.5) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _ in 0 ..< 2 {
|
||||
tapTerminal(in: element)
|
||||
if selectionTextView().waitForExistence(timeout: 0.25) {
|
||||
dismissSelectionSheet()
|
||||
}
|
||||
if waitForKeyboardFocus(in: element, timeout: 2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
XCTFail("Terminal did not acquire keyboard focus before typing")
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForKeyboardFocus(
|
||||
in element: XCUIElement,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate(format: "hasKeyboardFocus == true"),
|
||||
object: element
|
||||
)
|
||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
#endif
|
||||
|
||||
private func longPressTerminal(in element: XCUIElement, offset: CGVector? = nil) {
|
||||
element.coordinate(withNormalizedOffset: offset ?? terminalInteractionOffset).press(forDuration: 0.7)
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func dragPointerSelection(in element: XCUIElement) {
|
||||
log("pointer-selection-coordinates", "start=(0.008, 0.045), end=(0.42, 0.045), rightClick=(0.20, 0.045)")
|
||||
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.008, dy: 0.045))
|
||||
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.42, dy: 0.045))
|
||||
start.press(forDuration: 0.1, thenDragTo: end)
|
||||
}
|
||||
#else
|
||||
private var isIPad: Bool {
|
||||
UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
private func dragIPadPointerSelection(in element: XCUIElement) {
|
||||
log(
|
||||
"ipad-pointer-selection-coordinates",
|
||||
"frame=\(element.frame) command=echo \(iPadPointerSelectionPrefix)\(expectedPointerSelection) start=(0.015, 0.035), end=(0.205, 0.035), rightClick=(0.10, 0.035)"
|
||||
)
|
||||
let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.015, dy: 0.035))
|
||||
let end = element.coordinate(withNormalizedOffset: CGVector(dx: 0.205, dy: 0.035))
|
||||
start.click(forDuration: 0.1, thenDragTo: end)
|
||||
}
|
||||
|
||||
private func hideSoftwareKeyboardIfVisible() {
|
||||
let hideKeyboard = app.buttons["Hide keyboard"].firstMatch
|
||||
if hideKeyboard.waitForExistence(timeout: 1), hideKeyboard.isHittable {
|
||||
hideKeyboard.tap()
|
||||
XCTAssertFalse(hideKeyboard.waitForExistence(timeout: 2))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func openCopyMenuAndCopySelection(
|
||||
in element: XCUIElement,
|
||||
screenshotName: String,
|
||||
rightClickOffset: CGVector? = nil
|
||||
) {
|
||||
UIPasteboard.general.string = nil
|
||||
let offset = rightClickOffset ?? CGVector(dx: 0.20, dy: 0.045)
|
||||
log("pointer-copy-menu-coordinate", "frame=\(element.frame) rightClick=(\(offset.dx), \(offset.dy))")
|
||||
element.coordinate(withNormalizedOffset: offset).rightClick()
|
||||
let copy = copyMenuItem()
|
||||
if !copy.waitForExistence(timeout: 3) {
|
||||
capture("\(screenshotName)-missing")
|
||||
XCTFail("Copy menu item not found after pointer selection right click. Hierarchy: \(app.debugDescription)")
|
||||
return
|
||||
}
|
||||
capture(screenshotName)
|
||||
activateCopyMenuItem(copy)
|
||||
let actual = copiedSelectionText(in: element, timeout: 2)
|
||||
log("pointer-selection-pasteboard", actual ?? "<nil>")
|
||||
XCTAssertEqual(actual, expectedPointerSelection)
|
||||
}
|
||||
|
||||
private func activateCopyMenuItem(_ copy: XCUIElement) {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
copy.click()
|
||||
#else
|
||||
copy.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func copyMenuItem() -> XCUIElement {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.menuItems["Copy"].firstMatch
|
||||
#else
|
||||
app.buttons["Copy"].firstMatch
|
||||
#endif
|
||||
}
|
||||
|
||||
private func copiedSelectionText(in element: XCUIElement, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if let string = copiedSelectionTextSnapshot(in: element, timeout: 0.25) {
|
||||
return string
|
||||
}
|
||||
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
} while Date() < deadline
|
||||
return nil
|
||||
}
|
||||
|
||||
private func copiedSelectionTextSnapshot(in element: XCUIElement, timeout: TimeInterval) -> String? {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
return element.value as? String
|
||||
#else
|
||||
_ = timeout
|
||||
return UIPasteboard.general.string
|
||||
#endif
|
||||
}
|
||||
|
||||
private func log(_ name: String, _ value: String) {
|
||||
let attachment = XCTAttachment(string: value)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var isIPad: Bool {
|
||||
false
|
||||
}
|
||||
#endif
|
||||
|
||||
private var terminalInteractionOffset: CGVector {
|
||||
CGVector(dx: 0.5, dy: 0.55)
|
||||
}
|
||||
|
||||
private func tapAccessoryButton(_ label: String, screenshotName: String) {
|
||||
dismissKeyboardOnboardingIfVisible()
|
||||
let button = app.buttons[label]
|
||||
guard button.waitForExistence(timeout: 2), button.isHittable else {
|
||||
capture("\(screenshotName)-not-visible")
|
||||
return
|
||||
}
|
||||
button.tap()
|
||||
capture(screenshotName)
|
||||
}
|
||||
|
||||
private func dismissKeyboardOnboardingIfVisible() {
|
||||
let continueButton = app.buttons["Continue"].firstMatch
|
||||
guard continueButton.waitForExistence(timeout: 1), continueButton.isHittable else {
|
||||
return
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
continueButton.click()
|
||||
#else
|
||||
continueButton.tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openThemeMenuAndSelectPopularTheme() {
|
||||
let themeButton = app.buttons["terminal.themeButton"].firstMatch
|
||||
guard themeButton.waitForExistence(timeout: 2), themeButton.isHittable else {
|
||||
capture("theme-button-not-visible")
|
||||
return
|
||||
}
|
||||
themeButton.tap()
|
||||
capture("theme-menu-open")
|
||||
|
||||
let popular = app.buttons["Popular"].firstMatch
|
||||
if popular.waitForExistence(timeout: 1), popular.isHittable {
|
||||
popular.tap()
|
||||
capture("theme-menu-popular")
|
||||
}
|
||||
|
||||
let dracula = app.buttons["Dracula"].firstMatch
|
||||
if dracula.waitForExistence(timeout: 2), dracula.isHittable {
|
||||
dracula.tap()
|
||||
} else {
|
||||
capture("theme-dracula-not-visible")
|
||||
dismissOpenMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissOpenMenu() {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
app.typeKey(.escape, modifierFlags: [])
|
||||
#else
|
||||
let window = app.windows.firstMatch
|
||||
guard window.exists else { return }
|
||||
window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func dismissSelectionSheet() {
|
||||
let done = app.buttons["terminal.selectionDoneButton"].firstMatch
|
||||
if done.waitForExistence(timeout: 2), done.isHittable {
|
||||
done.tap()
|
||||
XCTAssertTrue(selectionTextView().waitForNonExistence(timeout: 3))
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionTextView() -> XCUIElement {
|
||||
app.textViews["terminal.selectionTextView"].firstMatch
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
guard let app else { return }
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
private var expectedPointerSelection: String {
|
||||
"selection anchor"
|
||||
}
|
||||
|
||||
private var iPadPointerSelectionPrefix: String {
|
||||
// iPadOS/XCTest clamps the indirect pointer drag start a couple of
|
||||
// cells inside the view edge. The prefix keeps the single fixed drag
|
||||
// selecting the same expected terminal text without retries.
|
||||
"xx"
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var catalystPointerSelectionPrefix: String {
|
||||
// Mac Catalyst clamps the left-edge pointer drag inside the first
|
||||
// text cell on GitHub runners. The prefix keeps the copied text
|
||||
// anchored to the same expected selection.
|
||||
"x"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user