Files
Screens/ScreensUITests/ScreensUITests.swift
Trey T 0e25dbeba4 Fix the keyboard typing pipeline + add a test that catches the regression
The earlier UIKeyInput conformance was declared in a separate extension. ObjC
protocol conformance via Swift extension is fragile when the protocol
inherits another @objc protocol (UIKeyInput inherits UITextInputTraits) — the
runtime didn't always pick up insertText:, so the on-screen keyboard came
up but characters never reached controller.type(_:).

Fix: declare UIKeyInput conformance directly on FramebufferUIView's class
declaration, with insertText / deleteBackward / hasText as native members.

Also caught and fixed by the new UI test:
- The toolbar's keyboard-icon button had a 20×13 hit region (SF Symbol size)
  even though the visual frame was 34×34 — XCUI taps couldn't land on it
  reliably. .contentShape(Rectangle()) widens the hit area to the frame.
- accessibilityValue is reserved by iOS for UIKeyInput-classed views (treats
  them as TextView), so a separate hidden "fb-diag" accessibility probe
  records keyboard plumbing events for the test to verify.

Tests:
- KeyboardInputTests (5): pure mapping from String → X11 keysym down/up pairs
- ScreensUITests.testSoftwareKeyboardSendsCharactersToFramebuffer:
  opens a session, taps the keyboard toggle, types "hi" via the system
  keyboard, and asserts the framebuffer's diagnostic probe records
  [ins:h] and [ins:i] — proving the chars reach controller.type(_:)
- A SwiftUI state probe (sessionview-state) verifies the binding flips,
  which guards against future tap-routing regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:04:03 -05:00

130 lines
5.7 KiB
Swift

import XCTest
final class ScreensUITests: XCTestCase {
override func setUp() {
continueAfterFailure = false
}
@MainActor
func testLayoutFillsScreenAndCoreFlows() {
let app = XCUIApplication()
app.launch()
// ---- Top chrome is present and flush with the safe area top.
let title = app.staticTexts["Screens"]
XCTAssertTrue(title.waitForExistence(timeout: 5), "Title should appear")
let screenFrame = app.windows.firstMatch.frame
let titleY = title.frame.midY
XCTAssertLessThan(titleY, screenFrame.height * 0.25,
"Title should sit in the top 25% of the screen; actual Y \(titleY) in screen \(screenFrame.height)")
// ---- Tap + to open Add Connection sheet.
let addButton = app.buttons["Add connection"]
XCTAssertTrue(addButton.exists, "Add button should exist")
addButton.tap()
let displayNameField = app.textFields["Display name"]
XCTAssertTrue(displayNameField.waitForExistence(timeout: 2),
"Add Connection sheet should present and show Display name field")
app.buttons["Cancel"].tap()
XCTAssertFalse(displayNameField.waitForExistence(timeout: 1),
"Add sheet should dismiss on Cancel")
// ---- Settings gear opens Settings sheet.
app.buttons["Settings"].tap()
let settingsTitle = app.navigationBars.staticTexts["Settings"]
XCTAssertTrue(settingsTitle.waitForExistence(timeout: 2),
"Settings sheet should present")
app.buttons["Done"].tap()
// ---- Search field accepts input and clears.
let search = app.textFields.matching(NSPredicate(format: "placeholderValue == %@", "Search connections")).firstMatch
XCTAssertTrue(search.waitForExistence(timeout: 2), "Search field should exist")
search.tap()
search.typeText("mini")
XCTAssertEqual(search.value as? String, "mini",
"Search text should round-trip")
XCTAssertTrue(title.isHittable, "Title should remain on screen during search")
// ---- Empty-state CTA routes to Add Connection.
let emptyCTA = app.buttons["Add a computer"]
if emptyCTA.waitForExistence(timeout: 1) {
app.buttons["Clear search"].tap()
emptyCTA.tap()
XCTAssertTrue(displayNameField.waitForExistence(timeout: 2),
"Empty-state CTA should present Add Connection sheet")
app.buttons["Cancel"].tap()
}
}
/// Adds a connection with an unreachable host so the SessionView opens
/// (controller exists, no real network needed) and verifies that tapping
/// the keyboard icon presents the iOS keyboard and that pressing keys
/// flows through the framebuffer view's input handling.
@MainActor
func testSoftwareKeyboardSendsCharactersToFramebuffer() {
let app = XCUIApplication()
app.launch()
// Add a bogus connection
app.buttons["Add connection"].tap()
let nameField = app.textFields["Display name"]
XCTAssertTrue(nameField.waitForExistence(timeout: 2))
nameField.tap()
nameField.typeText("KeyboardTest")
let hostField = app.textFields["Host or IP"]
hostField.tap()
hostField.typeText("127.0.0.1")
app.buttons["Add"].tap()
// Open the connection
app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "KeyboardTest")).firstMatch.tap()
// Framebuffer exists (as a TextView once UIKeyInput is adopted)
let framebuffer = app.descendants(matching: .any)
.matching(identifier: "framebuffer").firstMatch
XCTAssertTrue(framebuffer.waitForExistence(timeout: 5),
"Framebuffer view should exist in session")
// The diagnostic probe is a hidden sibling element that records
// keyboard plumbing events via its accessibilityLabel.
let diag = app.descendants(matching: .any)
.matching(identifier: "fb-diag").firstMatch
XCTAssertTrue(diag.waitForExistence(timeout: 3),
"Diagnostic probe should exist")
// State probe
let state = app.descendants(matching: .any)
.matching(identifier: "sessionview-state").firstMatch
XCTAssertTrue(state.waitForExistence(timeout: 3))
XCTAssertEqual(state.label, "kb=false", "Initial binding should be false")
// Tap the keyboard toggle in the toolbar
let kbToggle = app.buttons["Toggle keyboard bar"]
XCTAssertTrue(kbToggle.waitForExistence(timeout: 2))
kbToggle.tap()
// Verify the binding flipped
let flippedTrue = NSPredicate(format: "label == %@", "kb=true")
wait(for: [expectation(for: flippedTrue, evaluatedWith: state)], timeout: 3)
// Wait for the diagnostic to record that we asked for the keyboard.
// The framebuffer was already first responder (for hardware keys), so
// we hit the reload path rather than `became:true`, which is fine.
let askedForKeyboard = NSPredicate(format: "label CONTAINS %@", "[set:true]")
wait(for: [expectation(for: askedForKeyboard, evaluatedWith: diag)],
timeout: 3)
// Type into the active first responder. Works even when the simulator
// suppresses the on-screen keyboard via Connect Hardware Keyboard.
app.typeText("hi")
let typedH = NSPredicate(format: "label CONTAINS %@", "[ins:h]")
wait(for: [expectation(for: typedH, evaluatedWith: diag)], timeout: 3)
let typedI = NSPredicate(format: "label CONTAINS %@", "[ins:i]")
wait(for: [expectation(for: typedI, evaluatedWith: diag)], timeout: 3)
}
}