diff --git a/OwnFrame/OwnFrameApp.swift b/OwnFrame/OwnFrameApp.swift index 7f0c699e..7f93aa7f 100644 --- a/OwnFrame/OwnFrameApp.swift +++ b/OwnFrame/OwnFrameApp.swift @@ -19,6 +19,9 @@ import PurchaseKit import SlideshowKit import SwiftUI import ThemeKit +#if DEBUG +import ObjectiveC +#endif @main struct OwnFrameApp: App { @@ -103,6 +106,13 @@ struct OwnFrameApp: App { } init() { + #if DEBUG + // Defuse an unfixed iPadOS focus-engine simulator abort before any window exists (issue + // #42). No-op unless launched with `--uitest`; entirely absent from Release. See + // FocusEngineUITestWorkaround for the full rationale. + FocusEngineUITestWorkaround.installIfNeeded() + #endif + // The intents registry exists before either factory branch (800): created // once per process and handed to the intent shells' composition seam // (FrameIntentContext — see there for why not AppDependencyManager); @@ -862,6 +872,38 @@ private struct RootView: View { } #if DEBUG +// MARK: - Focus-engine UI-test workaround (DEBUG only) + +/// Neutralizes an unfixed iPadOS focus-engine abort that reproduces only in the *simulator* under +/// UI tests (issue #42). +/// +/// When a sheet is presented on an iPad with a hardware keyboard attached, UIKit resolves the new +/// modal's initial focus by *inferring* a default focus item, and that inference can abort in +/// `-[_UIFocusContainerGuideFallbackItemsContainer initWithParentEnvironment:childItems:]` on +/// `Invalid parameter not satisfying: parentEnvironment != nil`. An Apple DTS engineer has confirmed +/// this as an internal, still-unfixed UIKit bug (r.154431813) with no view-level workaround, and it +/// fires ONLY while a hardware keyboard is connected. The tip jar's loading state happens to trip it +/// where the structurally similar unlock sheet does not — but no `.sheet(item:)`, `NavigationStack`, +/// `.presentationDetents`, `.focusable`, or `.defaultFocus` arrangement prevents it reliably (all +/// were tried against issue #42). +/// +/// The wall-mounted production frame has no keyboard and never runs this path, so there is nothing to +/// fix in shipping code. The simulator, however, defaults to a connected hardware keyboard, which is +/// the sole reason the UI tests hit the abort. This shim is compiled ONLY into DEBUG builds (never +/// the App Store binary) and installed ONLY under `--uitest`; it replaces `-[UIFocusSystem +/// updateFocusIfNeeded]` — the focus pass that drives the inference — with a no-op. XCUITest drives +/// the UI through the accessibility API, not the focus engine, so nothing under test depends on it. +enum FocusEngineUITestWorkaround { + static func installIfNeeded() { + guard ProcessInfo.processInfo.arguments.contains("--uitest") else { return } + guard let focusSystem = NSClassFromString("UIFocusSystem"), + let method = class_getInstanceMethod(focusSystem, NSSelectorFromString("updateFocusIfNeeded")) + else { return } + let noop: @convention(block) (AnyObject) -> Void = { _ in } + method_setImplementation(method, imp_implementationWithBlock(noop)) + } +} + // MARK: - UI test seam (DEBUG only) // // Activated solely by the `--uitest` launch argument that the XCUITest target diff --git a/OwnFrameUITests/TipJarPresentationUITests.swift b/OwnFrameUITests/TipJarPresentationUITests.swift new file mode 100644 index 00000000..9ca3c607 --- /dev/null +++ b/OwnFrameUITests/TipJarPresentationUITests.swift @@ -0,0 +1,98 @@ +// +// TipJarPresentationUITests.swift +// OwnFrameUITests +// +// 1100 / US6 (FR-1100-08) — the tip jar is optional, reachable ONLY via settings, and +// presents a real sheet when its row is tapped. This is the durable regression guard for +// that "reachable + presents" requirement, driven hermetically through the `--uitest-store=` +// and `--uitest-entitlements=` seams (contracts/uitest-seams.md). StoreKit is never reached. +// +// Ungated, English-only, part of the normal suite — unlike GermanScreenshotSweepUITests, +// whose tip-jar screens (74–76) are SCREENSHOT_DE-gated and first captured this as a crash +// rather than an assertion. See issue #42: with the simulator's hardware keyboard connected +// (the default), presenting this sheet in landscape aborts the app inside UIKit's focus +// engine — an unfixed UIKit bug (Apple r.154431813), defused for UI tests by +// FocusEngineUITestWorkaround in OwnFrameApp.swift. This file asserts the sheet actually +// presents (`tipjar.screen`), so it goes RED again if that shim regresses. +// + +import XCTest + +final class TipJarPresentationUITests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + // Never inherit a rotation leaked by an earlier test on the same simulator clone. + MainActor.assumeIsolated { XCUIDevice.shared.orientation = .portrait } + } + + // MARK: - FR-1100-08 — the tip jar presents from settings + + /// The whole of FR-1100-08's "reachable via settings" leg: a user in Settings scrolls to + /// the Unlocks section, taps "Leave a Tip", and the tip jar sheet actually appears. Nothing + /// exotic — a plain scroll + single tap, the minimal realistic gesture. + /// + /// Without the issue-#42 shim, presenting the sheet aborts the app in UIKit's focus engine + /// before `tipjar.screen` ever mounts, and the wait times out on a dead app. + @MainActor + func testTipJarPresentsFromSettings() throws { + let app = launchIntoSettings() + + let tipRow = element(app, "settings.tipjar") + XCTAssertTrue(scrollToElement(tipRow, in: app), + "the 'Leave a Tip' row must be reachable in the Unlocks section (FR-1100-08)") + tipRow.tap() + + XCTAssertTrue(element(app, "tipjar.screen").waitForExistence(timeout: 10), + "tapping settings.tipjar must present the tip jar sheet (tipjar.screen) (FR-1100-08)") + } + + // MARK: - Helpers + + /// Launches straight into the settings sheet over the hermetic stub slideshow, unentitled + /// (the tip jar lives in the Unlocks section on every tier) with the stub store wired so the + /// tip jar has real products to price. Mirrors PurchaseGateUITests' launch seam: + /// `--uitest-chrome` pins the chrome, `--uitest-settings` opens the sheet without a tap, + /// `--uitest-reset-theme` clears any persisted UI-test theme state. + @MainActor + private func launchIntoSettings() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "--uitest", "--uitest-slideshow", "--uitest-chrome", "--uitest-settings", + "--uitest-reset-theme", "--uitest-entitlements=none", "--uitest-store=stub", + ] + app.launch() + // Landscape is LOAD-BEARING for this regression, not cosmetic: the frame lives on a + // wall-mounted iPad in landscape (as the sweep drives it), and the issue #42 focus-engine + // abort only fires when the tip jar sheet is presented over a landscape iPad Form. In + // portrait the very same navigation presents cleanly. Do not "simplify" this to portrait — + // that turns the test green without fixing the crash. (Reproduces on iPadOS 26.0 and 18.6.) + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } + XCTAssertTrue(app.sliders["settings.brightness"].waitForExistence(timeout: 10), + "settings should be open") + return app + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: identifier).firstMatch + } + + /// Swipes up until the element exists (or the swipe budget is exhausted). + @MainActor + private func scrollToElement( + _ element: XCUIElement, + in app: XCUIApplication, + maxSwipes: Int = 8 + ) -> Bool { + if element.waitForExistence(timeout: 3) { return true } + var swipes = 0 + while !element.exists && swipes < maxSwipes { + app.swipeUp() + swipes += 1 + } + return element.exists + } +} diff --git a/docs/testing.md b/docs/testing.md index 04818735..8afb4f27 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -303,6 +303,16 @@ of hanging the run. is the source of truth. - **`xcodebuild` never extracts strings into `Localizable.xcstrings`** — only the Xcode IDE does. Hand-edit surgically; don't JSON round-trip (Xcode sorts by ICU collation). +- **The simulator's connected hardware keyboard can SIGABRT sheet presentation** (issue #42): + presenting the tip jar sheet over a landscape iPad Form aborts in UIKit's focus engine + (`_UIFocusContainerGuideFallbackItemsContainer`, `parentEnvironment != nil` assert) — an + unfixed UIKit bug (Apple r.154431813, DTS-confirmed, iPadOS 18.6 and 26.0 alike) that runs + only while a hardware keyboard is attached. No view-level arrangement prevents it + (`.sheet(item:)`, `NavigationStack`, detents, focus modifiers — all tried). Defused by + `FocusEngineUITestWorkaround` in `OwnFrameApp.swift`: DEBUG-only, `--uitest`-gated, no-ops + `UIFocusSystem.updateFocusIfNeeded`, which XCUITest (accessibility-driven) never needs. The + keyboardless production frame never runs this path. Regression guard: + `TipJarPresentationUITests` (landscape is load-bearing there — portrait masks the bug). ## Live-server contract check (manual, opt-in)