Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions OwnFrame/OwnFrameApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import PurchaseKit
import SlideshowKit
import SwiftUI
import ThemeKit
#if DEBUG
import ObjectiveC
#endif

@main
struct OwnFrameApp: App {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions OwnFrameUITests/TipJarPresentationUITests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading