diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4018b7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +`gterm` is an iOS SwiftUI SSH terminal app backed by Ghostty's rendering engine. Source lives under `Sources/`: + +- `Sources/App/`: app entry point. +- `Sources/UI/`: SwiftUI screens and connection/key management views. +- `Sources/Ghostty/`: Swift bridge and terminal surface/input integration. +- `Sources/SSH/`: `swift-nio-ssh` transport, PTY channel, known-host handling, and key parsing. +- `Sources/Terminal/` and `Sources/Model/`: terminal protocol abstractions, persistence, and Keychain helpers. + +`project.yml` is the XcodeGen source of truth. `gterm.xcodeproj`, `Info.plist`, and `GhosttyKit.xcframework` are generated and ignored. `scripts/build-ghostty-xcframework.sh` builds the Ghostty engine from a sibling `../ghostty` checkout. + +## Build, Test, and Development Commands + +- `./scripts/build-ghostty-xcframework.sh`: build and copy `GhosttyKit.xcframework`. Requires patched Homebrew `zig@0.15`; override with `ZIG=...` or `GHOSTTY_DIR=...`. +- `xcodegen generate`: regenerate `gterm.xcodeproj` and generated app metadata from `project.yml`. +- `xcodebuild -project gterm.xcodeproj -scheme gterm -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build`: compile the app for simulator without signing. +- Open `gterm.xcodeproj` in Xcode for simulator/device runs after regeneration. + +## Coding Style & Naming Conventions + +Use Swift 5 language mode, four-space indentation, and existing Swift API naming. Types use `UpperCamelCase`; methods, properties, and enum cases use `lowerCamelCase`. Keep UI code in `Sources/UI`, SSH/networking code in `Sources/SSH`, and C/Ghostty interop isolated in `Sources/Ghostty`. Prefer small, explicit types and avoid blocking callbacks from Ghostty or NIO threads; hop to the appropriate event loop or main thread. + +## Testing Guidelines + +Three test targets exist, all defined in `project.yml`: `gtermTests` (logic-only, compiles `Sources/LLM` + `Tests/`, run with `xcodebuild -scheme gtermTests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' test`), `gtermSSHTests` (macOS), and `gtermUITests` (XCUITest, `UITests/`). Run the UI suite offscreen with `python3 scripts/test-ipad-ui.py` (needs `paramiko`; `--device-type NAME` targets another simulator such as `"iPhone 17 Pro Max"` or the iPhone Duo). Every change should at least pass the simulator build command above. For behavior touching SSH auth, host-key trust, terminal input, resize, or Keychain persistence, also do a manual simulator/device smoke test. Name test files after the feature under test, for example `SSHKeyParserTests.swift`. + +## Commit & Pull Request Guidelines + +Recent commits use concise, imperative subjects with the `gterm:` prefix, for example `gterm: SSH private-key management`. Keep commits focused and mention generated artifacts only when the source file that produces them changed. PRs should describe the user-visible behavior, list build/manual verification, link related issues, and include screenshots or short recordings for UI changes. + +## Security & Configuration Tips + +Do not commit secrets, private keys, provisioning material, or local config. `.gitignore` already excludes `*.p8`, `local.properties`, `secrets.xcconfig`, generated build output, and `GhosttyKit.xcframework`. Store SSH passwords and imported keys through the app's Keychain paths, not in source fixtures or logs. diff --git a/CLAUDE.md b/CLAUDE.md index e67bc66..e5e9299 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,15 @@ Terminal resize → passthru resize callback → SSHSession → WindowChangeRequ Passthru callbacks fire on ghostty's IO thread — always hop to the NIO event loop (SSH) or main thread (UI) and never block. +### Layout selection (iPad vs iPhone vs iPhone Duo) + +`RootView.usesWorkspace` picks the split-view workspace when the idiom is +`.pad` or the window is regular in **both** size classes (the iPhone Duo's +inner display); everything else gets the phone tabs. Do not use +`horizontalSizeClass == .regular` alone: Max-class iPhones are regular-width +in landscape and must keep tabs. Never reintroduce `UIScreen.main`; use +`TerminalSurfaceView.displayScale`, which follows the display the view is on. + ### C interop GhosttyKit is a static Zig library linked with `-ObjC -lstdc++`. The `Ghostty/` module uses `Unmanaged`, C function-pointer callbacks, and raw struct access against `ghostty.h`. The project uses **Swift 5 language mode** because this low-level interop is substantially simpler than under Swift 6 strict concurrency. @@ -117,4 +126,5 @@ GhosttyKit is a static Zig library linked with `-ObjC -lstdc++`. The `Ghostty/` - Team: from `DEVELOPMENT_TEAM` in the git-ignored `.env` (see `env.example`); App Store Connect only accepts builds signed by the current team there — the old `SK4GFF6AHN` team is stale - Version/build: `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml` - Dependencies: swift-nio-ssh, swift-crypto, OpenAI (MacPaw), SwiftAnthropic +- iPhone Duo: full edge-to-edge use of the inner display needs a build against the iOS 27.1 SDK (Xcode 27.1+); the deployment target stays 17.0 - CI: `.github/workflows/build.yml` — builds the engine on macOS-15, caches `~/.cache/zig` diff --git a/README.md b/README.md index 162d54d..40498c2 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,18 @@ connection via a custom **passthru IO backend** added to libghostty. See Keychain, device-only); each host can select one or more keys to try - ⏳ Encrypted (passphrase) keys, RSA, richer settings (font/theme) -## iPad and Magic Keyboard +## iPad, iPhone Duo, and Magic Keyboard On iPad, Hosts stays in a collapsible sidebar beside the terminal. Use the Sessions menu to switch connections without disconnecting them. Keys, AI, and Settings open in sheets; narrow windows collapse to one column. +iPhone Duo gets the same sidebar workspace on its inner display, where the +window is regular in both size classes. The outer display and Split View use +the iPhone tab layout. Sessions stay connected across folding; the terminal +surface simply moves between the two layouts. Full edge-to-edge use of the +inner display requires a build against the iOS 27.1 SDK. + Magic Keyboard supports two-finger scrollback, pointer drag selection, and Command-click to open terminal links. The keyboard button hides the software keyboard while keeping physical keyboard input focused on the terminal. @@ -56,6 +62,10 @@ starts the loopback SSH fixture, and runs all UI tests. It removes its device and stops its fixture on completion or interruption. Existing simulators are left alone. Logs and the `.xcresult` bundle (including screenshots) remain in the printed temporary directory. Use `--derived-data PATH` to reuse a build cache. +`--device-type NAME` picks another simulator: `"iPhone 17 Pro Max"` runs the +phone layout tests (the iPad window test skips itself), and the iPhone Duo +simulator can be targeted the same way once Xcode 27.1 is installed. +`--only-testing gtermUITests/PhoneLayoutTests` narrows the run. Port 62222 must be free for the fixture. The keyboard test exercises an SSH connection, keyboard dispatch with the software keyboard hidden, session diff --git a/Sources/Ghostty/TerminalSurfaceView+Scroll.swift b/Sources/Ghostty/TerminalSurfaceView+Scroll.swift index 5088ba6..51ed79b 100644 --- a/Sources/Ghostty/TerminalSurfaceView+Scroll.swift +++ b/Sources/Ghostty/TerminalSurfaceView+Scroll.swift @@ -35,7 +35,7 @@ extension TerminalSurfaceView { // Convert points to surface pixels and feed as a precision scroll so // the content follows the finger. Positive y scrolls toward older // lines, which is what dragging the content downward should do. - let scale = window?.screen.scale ?? UIScreen.main.scale + let scale = displayScale let yOffset = Double(delta * scale) ghostty_surface_mouse_scroll(surface, 0, yOffset, Self.precisionScrollMods) default: diff --git a/Sources/Ghostty/TerminalSurfaceView.swift b/Sources/Ghostty/TerminalSurfaceView.swift index 61093d3..94bf960 100644 --- a/Sources/Ghostty/TerminalSurfaceView.swift +++ b/Sources/Ghostty/TerminalSurfaceView.swift @@ -30,6 +30,8 @@ extension TerminalSurfaceViewDelegate { /// ghostty key/text C API; received bytes are pushed in via `receive(_:)`. final class TerminalSurfaceView: UIView { var onKeyboardShortcut: ((String) -> Void)? + /// The representable currently hosting this surface; see TerminalView. + weak var host: AnyObject? private var softwareKeyboardHidden = false private lazy var hiddenKeyboardView = UIView(frame: .zero) override var inputView: UIView? { softwareKeyboardHidden ? hiddenKeyboardView : nil } @@ -56,6 +58,12 @@ final class TerminalSurfaceView: UIView { isOpaque = true contentMode = .redraw + // Moving between displays with different scales (iPhone Duo) must + // re-sync the surface's pixel size. + registerForTraitChanges([UITraitDisplayScale.self]) { (view: TerminalSurfaceView, _) in + view.setNeedsLayout() + } + // Pinch: font size normally; Herdr pane zoom when attachHerdr is on. let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:))) addGestureRecognizer(pinch) @@ -93,7 +101,7 @@ final class TerminalSurfaceView: UIView { cfg.platform_tag = GHOSTTY_PLATFORM_IOS cfg.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(uiview: selfPtr)) cfg.userdata = selfPtr - cfg.scale_factor = Double(window?.screen.scale ?? UIScreen.main.scale) + cfg.scale_factor = Double(displayScale) cfg.font_size = 0 cfg.io_backend = GHOSTTY_IO_BACKEND_PASSTHRU cfg.pty_write_cb = { ud, ptr, len in @@ -229,9 +237,18 @@ final class TerminalSurfaceView: UIView { CATransaction.commit() } + /// Scale of the display currently showing this view. `UIScreen.main` is + /// deprecated and wrong on multi-display devices (iPhone Duo inner/outer); + /// `displayScale` is 0 when unspecified, hence the fallback. + var displayScale: CGFloat { + if let scale = window?.windowScene?.screen.scale, scale > 0 { return scale } + let trait = traitCollection.displayScale + return trait > 0 ? trait : 2 + } + private func syncSize() { guard let surface else { return } - let scale = window?.screen.scale ?? UIScreen.main.scale + let scale = displayScale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) let size = bounds.size ghostty_surface_set_size( diff --git a/Sources/UI/RootView.swift b/Sources/UI/RootView.swift index e26d63a..0856239 100644 --- a/Sources/UI/RootView.swift +++ b/Sources/UI/RootView.swift @@ -6,6 +6,8 @@ struct RootView: View { @StateObject private var keys = KeyStore() @StateObject private var forwards = PortForwardStore() @StateObject private var sessions = SessionManager() + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass @State private var activeSession: ActiveSession? @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var preferredColumn: NavigationSplitViewColumn = .sidebar @@ -21,10 +23,24 @@ struct RootView: View { @AppStorage("hasSeenWelcome") private var hasSeenWelcome = false @State private var showWelcome = false + /// Wide layouts use the split-view workspace: iPad (any window width; the + /// split view collapses itself when narrow) and phones whose window is + /// regular in both axes, i.e. the iPhone Duo's inner display. Deliberately + /// not `horizontalSizeClass == .regular` alone: Max-class iPhones are + /// regular-width in landscape and must keep the tab layout. Read here at + /// the window root; NavigationSplitView overrides size classes per column. + private var usesWorkspace: Bool { + #if DEBUG + if UserDefaults.standard.bool(forKey: "gtermForceWorkspaceLayout") { return true } + #endif + return UIDevice.current.userInterfaceIdiom == .pad + || (horizontalSizeClass == .regular && verticalSizeClass == .regular) + } + var body: some View { Group { - if UIDevice.current.userInterfaceIdiom == .pad { - iPadWorkspace + if usesWorkspace { + workspace } else { phoneTabs .fullScreenCover(item: $activeSession) { session in @@ -32,6 +48,14 @@ struct RootView: View { } } } + .onChange(of: usesWorkspace) { _, wide in + // Folding an iPhone Duo tears the workspace's utility sheet down + // without onDismiss; clear it so unfolding doesn't re-present it. + if !wide { + utility = nil + welcomeAfterUtility = false + } + } .fullScreenCover(isPresented: $showWelcome) { OnboardingView { showWelcome = false @@ -46,7 +70,7 @@ struct RootView: View { private var hosts: some View { ConnectionListView( store: connections, keyStore: keys, forwardStore: forwards, sessions: sessions, - usesNavigationStack: UIDevice.current.userInterfaceIdiom != .pad, + usesNavigationStack: !usesWorkspace, selectedSessionID: activeSession?.id ) { connection in let connectionForwards = connection.savedID.map { forwards.forwards(for: $0) } ?? [] @@ -67,7 +91,7 @@ struct RootView: View { } } - private var iPadWorkspace: some View { + private var workspace: some View { NavigationSplitView(columnVisibility: $columnVisibility, preferredCompactColumn: $preferredColumn) { hosts .safeAreaInset(edge: .bottom) { diff --git a/Sources/UI/TerminalView.swift b/Sources/UI/TerminalView.swift index c05165d..7aa5de9 100644 --- a/Sources/UI/TerminalView.swift +++ b/Sources/UI/TerminalView.swift @@ -14,6 +14,10 @@ struct TerminalView: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> TerminalSurfaceView { + // The session-owned surface is reparented when the layout swaps (iPad + // session switch, iPhone Duo fold). Only its newest host may rebind + // handlers, whatever order the old and new representables update in. + surface.host = context.coordinator updateHandlers(context.coordinator) return surface } @@ -23,6 +27,7 @@ struct TerminalView: UIViewRepresentable { } private func updateHandlers(_ coordinator: Coordinator) { + guard surface.host === coordinator else { return } coordinator.onOpenURL = onOpenURL coordinator.onKeyboardShortcut = onKeyboardShortcut // SwiftUI can call onDisappear during split-view navigation while the diff --git a/UITests/PhoneLayoutTests.swift b/UITests/PhoneLayoutTests.swift new file mode 100644 index 0000000..ce3a924 --- /dev/null +++ b/UITests/PhoneLayoutTests.swift @@ -0,0 +1,50 @@ +import XCTest +import UIKit + +/// Phone-idiom layout rules. Run on an iPhone 17 Pro Max simulator, whose +/// landscape window is regular-width but compact-height: the size-class rule +/// in RootView must keep tabs there and reserve the workspace for windows that +/// are regular in both axes (the iPhone Duo's inner display). +final class PhoneLayoutTests: XCTestCase { + override func setUpWithError() throws { + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone) + } + + func testRegularWidthLandscapeKeepsTabs() throws { + let app = XCUIApplication() + app.launchArguments = ["-hasSeenWelcome", "YES"] + XCUIDevice.shared.orientation = .landscapeLeft + app.launch() + XCTAssertTrue(app.tabBars.buttons["Hosts"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["Select a Host"].exists) + capture("iPhone landscape tabs") + XCUIDevice.shared.orientation = .portrait + XCTAssertTrue(app.tabBars.buttons["Hosts"].waitForExistence(timeout: 5)) + } + + /// Exercises the workspace branch on the phone idiom ahead of the iPhone + /// Duo simulator: sidebar, utility sheet, connect, and Command-W back. + func testForcedWorkspaceLayoutOnPhone() throws { + let app = XCUIApplication() + app.launchArguments = ["-hasSeenWelcome", "YES", "-gtermForceWorkspaceLayout", "YES"] + XCUIDevice.shared.orientation = .landscapeLeft + app.launch() + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Select a Host"].exists) + XCTAssertFalse(app.tabBars.buttons["Hosts"].exists) + capture("iPhone forced workspace") + + app.buttons["Keys"].tap() + XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5)) + app.buttons["Done"].tap() + XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5)) + + connectTestHost(app, username: "test") + XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5)) + capture("iPhone forced workspace terminal") + app.typeKey("x", modifierFlags: []) + app.typeKey("w", modifierFlags: .command) + XCTAssertTrue(app.staticTexts["Select a Host"].waitForExistence(timeout: 5)) + XCUIDevice.shared.orientation = .portrait + } +} diff --git a/UITests/TestSupport.swift b/UITests/TestSupport.swift new file mode 100644 index 0000000..6dcf387 --- /dev/null +++ b/UITests/TestSupport.swift @@ -0,0 +1,47 @@ +import XCTest + +/// Helpers shared by the iPad and iPhone UI suites. +extension XCTestCase { + func connectTestHost(_ app: XCUIApplication, username: String) { + app.buttons["Add host"].tap() + let name = "Keyboard test \(UUID().uuidString.prefix(6))" + for (field, value) in [("name (optional)", name), ("host", "127.0.0.1"), ("username", username)] { + app.textFields[field].tap() + app.textFields[field].typeText(value) + } + app.textFields["port"].tap() + app.textFields["port"].typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 2) + "62222") + // Dismiss iPadOS's floating number-pad popover before tapping Save. + app.textFields["name (optional)"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.buttons["Save"].isEnabled) + app.buttons["Save"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + let host = app.staticTexts[name] + for _ in 0..<12 { + if host.isHittable && host.frame.maxY < app.buttons["Keys"].frame.minY - 12 { break } + app.collectionViews.firstMatch.swipeUp() + } + XCTAssertTrue(host.waitForExistence(timeout: 5)) + host.tap() + XCTAssertTrue(app.alerts["Password"].waitForExistence(timeout: 5)) + app.alerts.secureTextFields.firstMatch.typeText("test") + app.alerts.buttons["Connect"].tap() + if app.alerts["Unknown Host"].waitForExistence(timeout: 3) { + app.alerts.buttons["Trust"].tap() + } else if app.alerts["Host Key Changed"].exists { + // The loopback fixture generates an ephemeral key on every run. + app.alerts.buttons["Accept New Key"].tap() + } + // A fresh simulator may still be opening the terminal after the host + // header appears. Wait for SSH readiness before synthesizing keys. + let status = app.descendants(matching: .any)["connectionStatus"] + let connected = expectation(for: NSPredicate(format: "value == 'Connected'"), evaluatedWith: status) + wait(for: [connected], timeout: 10) + } + + func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/UITests/iPadWorkspaceTests.swift b/UITests/iPadWorkspaceTests.swift index 99a082a..3690053 100644 --- a/UITests/iPadWorkspaceTests.swift +++ b/UITests/iPadWorkspaceTests.swift @@ -6,9 +6,7 @@ final class iPadWorkspaceTests: XCTestCase { let app = XCUIApplication() app.launchArguments = ["-hasSeenWelcome", "YES"] XCUIDevice.shared.orientation = .landscapeLeft - app.launch() - maximizeWindow(app) - XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + try launchWorkspace(app) XCTAssertTrue(app.staticTexts["Select a Host"].exists) capture("iPad landscape workspace") @@ -37,9 +35,7 @@ final class iPadWorkspaceTests: XCTestCase { let app = XCUIApplication() app.launchArguments = ["-hasSeenWelcome", "YES"] XCUIDevice.shared.orientation = .landscapeLeft - app.launch() - maximizeWindow(app) - XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + try launchWorkspace(app) connectTestHost(app, username: "test") XCTAssertTrue(app.buttons["Toggle keyboard"].waitForExistence(timeout: 5)) app.buttons["Toggle keyboard"].tap() @@ -56,13 +52,16 @@ final class iPadWorkspaceTests: XCTestCase { app.typeKey("0", modifierFlags: .command) let terminal = app.descendants(matching: .any)["terminalSurface"] XCTAssertTrue(terminal.waitForExistence(timeout: 5)) - terminal.hover() - terminal.scroll(byDeltaX: 0, deltaY: -300) - capture("Magic Keyboard scrolled history") - let selectionStart = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.3)) - let selectionEnd = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.45, dy: 0.3)) - selectionStart.click(forDuration: 0.1, thenDragTo: selectionEnd) - capture("Magic Keyboard pointer selection") + // XCUITest synthesizes pointer events only on iPad. + if UIDevice.current.userInterfaceIdiom == .pad { + terminal.hover() + terminal.scroll(byDeltaX: 0, deltaY: -300) + capture("Magic Keyboard scrolled history") + let selectionStart = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.3)) + let selectionEnd = terminal.coordinate(withNormalizedOffset: CGVector(dx: 0.45, dy: 0.3)) + selectionStart.click(forDuration: 0.1, thenDragTo: selectionEnd) + capture("Magic Keyboard pointer selection") + } app.typeKey("c", modifierFlags: .command) app.typeKey("v", modifierFlags: .command) app.typeKey("a", modifierFlags: .command) @@ -86,12 +85,12 @@ final class iPadWorkspaceTests: XCTestCase { } func testWindowResizingKeepsHostsReachable() throws { + // Window corner drags and the title-edge double-tap are iPadOS-only. + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad) let app = XCUIApplication() app.launchArguments = ["-hasSeenWelcome", "YES"] XCUIDevice.shared.orientation = .landscapeLeft - app.launch() - maximizeWindow(app) - XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + try launchWorkspace(app) let originalFrame = mainWindow(app).frame let corner = mainWindow(app).coordinate(withNormalizedOffset: CGVector(dx: 0.995, dy: 0.99)) let target = mainWindow(app).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) @@ -111,6 +110,16 @@ final class iPadWorkspaceTests: XCTestCase { "window width \(mainWindow(app).frame.width) after restoring from \(originalFrame.width)") } + /// Launches into the sidebar workspace, or skips on devices that show the + /// tab layout (every shipping iPhone). The iPhone Duo's inner display is + /// regular in both axes and runs these tests like an iPad. + private func launchWorkspace(_ app: XCUIApplication) throws { + app.launch() + XCTAssertTrue(app.buttons["Add host"].waitForExistence(timeout: 10)) + try XCTSkipIf(app.tabBars.buttons["Hosts"].exists, "tab layout: workspace tests need a regular-by-regular window") + maximizeWindow(app) + } + /// The app owns several windows (keyboard, text effects) whose frames can /// be reported in a different orientation. Target the one hosting Hosts. private func mainWindow(_ app: XCUIApplication) -> XCUIElement { @@ -144,47 +153,4 @@ final class iPadWorkspaceTests: XCTestCase { } return mainWindow(app).frame.width >= minWidth } - - private func connectTestHost(_ app: XCUIApplication, username: String) { - app.buttons["Add host"].tap() - let name = "Keyboard test \(UUID().uuidString.prefix(6))" - for (field, value) in [("name (optional)", name), ("host", "127.0.0.1"), ("username", username)] { - app.textFields[field].tap() - app.textFields[field].typeText(value) - } - app.textFields["port"].tap() - app.textFields["port"].typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 2) + "62222") - // Dismiss iPadOS's floating number-pad popover before tapping Save. - app.textFields["name (optional)"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - XCTAssertTrue(app.buttons["Save"].isEnabled) - app.buttons["Save"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - let host = app.staticTexts[name] - for _ in 0..<12 { - if host.isHittable && host.frame.maxY < app.buttons["Keys"].frame.minY - 12 { break } - app.collectionViews.firstMatch.swipeUp() - } - XCTAssertTrue(host.waitForExistence(timeout: 5)) - host.tap() - XCTAssertTrue(app.alerts["Password"].waitForExistence(timeout: 5)) - app.alerts.secureTextFields.firstMatch.typeText("test") - app.alerts.buttons["Connect"].tap() - if app.alerts["Unknown Host"].waitForExistence(timeout: 3) { - app.alerts.buttons["Trust"].tap() - } else if app.alerts["Host Key Changed"].exists { - // The loopback fixture generates an ephemeral key on every run. - app.alerts.buttons["Accept New Key"].tap() - } - // A fresh simulator may still be opening the terminal after the host - // header appears. Wait for SSH readiness before synthesizing keys. - let status = app.descendants(matching: .any)["connectionStatus"] - let connected = expectation(for: NSPredicate(format: "value == 'Connected'"), evaluatedWith: status) - wait(for: [connected], timeout: 10) - } - - private func capture(_ name: String) { - let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) - attachment.name = name - attachment.lifetime = .keepAlways - add(attachment) - } } diff --git a/scripts/test-ipad-ui.py b/scripts/test-ipad-ui.py index 21602d8..c288e3b 100644 --- a/scripts/test-ipad-ui.py +++ b/scripts/test-ipad-ui.py @@ -24,6 +24,11 @@ def interrupted(signum, frame): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--derived-data", default=str(ROOT / "build/ui-tests")) + parser.add_argument("--device-type", default="iPad Pro 11-inch (M5)", + help="simctl device type name, e.g. 'iPhone 17 Pro Max' or the " + "iPhone Duo simulator once Xcode 27.1 ships") + parser.add_argument("--only-testing", action="append", default=[], + help="xcodebuild -only-testing identifier (repeatable)") args = parser.parse_args() # Fail before allocating a device if the fixture dependency is missing. run(sys.executable, "-c", "import paramiko") @@ -34,8 +39,11 @@ def main(): if not runtimes: parser.error("Install an iOS 26 or newer simulator runtime in Xcode.") runtime = max(runtimes, key=lambda r: tuple(map(int, r["version"].split(".")))) - device_type = next(d["identifier"] for d in runtime["supportedDeviceTypes"] - if d["name"] == "iPad Pro 11-inch (M5)") + device_types = {d["name"]: d["identifier"] for d in runtime["supportedDeviceTypes"]} + device_type = device_types.get(args.device_type) + if device_type is None: + parser.error(f"No device type {args.device_type!r} in {runtime['name']}. Available:\n " + + "\n ".join(sorted(device_types))) artifacts = Path(tempfile.mkdtemp(prefix="gterm-ui-tests-")) print(f"Test logs and screenshots: {artifacts}", flush=True) device = None @@ -55,19 +63,20 @@ def main(): raise RuntimeError("SSH fixture did not become ready within 10 seconds") device = run("xcrun", "simctl", "create", artifacts.name, device_type, runtime["identifier"], capture_output=True, text=True).stdout.strip() - print(f"Offscreen iPad: {device} ({runtime['name']})", flush=True) + print(f"Offscreen {args.device_type}: {device} ({runtime['name']})", flush=True) # simctl boots the device services without launching the Simulator UI. run("xcrun", "simctl", "boot", device) run("xcrun", "simctl", "bootstatus", device, "-b") run("xcodegen", "generate") with (artifacts / "xcodebuild.log").open("w") as build_log: + only = [arg for test in args.only_testing for arg in ("-only-testing", test)] run("xcodebuild", "-project", "gterm.xcodeproj", "-scheme", "gtermUITests", "-destination", f"platform=iOS Simulator,id={device}", "-parallel-testing-enabled", "NO", "-derivedDataPath", args.derived_data, - "-resultBundlePath", str(artifacts / "Tests.xcresult"), + "-resultBundlePath", str(artifacts / "Tests.xcresult"), *only, "CODE_SIGNING_ALLOWED=NO", "test", stdout=build_log, stderr=subprocess.STDOUT) - print("iPad UI tests passed.", flush=True) + print("UI tests passed.", flush=True) finally: if fixture is not None and fixture.poll() is None: fixture.terminate()