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>
130 lines
5.7 KiB
Swift
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)
|
|
}
|
|
}
|