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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user