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>
This commit is contained in:
Trey T
2026-04-16 23:04:03 -05:00
parent da882531d1
commit 0e25dbeba4
6 changed files with 209 additions and 69 deletions

View File

@@ -46,13 +46,11 @@ final class ScreensUITests: XCTestCase {
search.typeText("mini")
XCTAssertEqual(search.value as? String, "mini",
"Search text should round-trip")
// Title should still be visible (top chrome does not scroll off)
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) {
// clear search first
app.buttons["Clear search"].tap()
emptyCTA.tap()
XCTAssertTrue(displayNameField.waitForExistence(timeout: 2),
@@ -60,4 +58,72 @@ final class ScreensUITests: XCTestCase {
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)
}
}