From 94ed5728895cc7bb72734b62966d52ed1cfe86e2 Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Wed, 2 Sep 2026 13:40:02 -0400 Subject: [PATCH 1/2] ci: run the iOS UI suite on iPad as well as iPhone The app ships iPad (TARGETED_DEVICE_FAMILY "1,2") and the UI it presents there is structurally different: a NavigationSplitView with a sidebar rather than a compact NavigationStack with a navigationBarDrawer search field. The UI suite branches on the idiom, but only the iPhone half had ever run -- CI resolved a single iPhone simulator for every iOS step, so the iPad paths were dead code in practice. Adds ipad to the existing matrix. fail-fast is already false, so one device failing still reports the other. Unit tests stay pinned to the iphone leg; they are pure logic and device-independent. No other change was needed: Scripts/resolve-ios-simulator.sh already takes a device family, and the macos-26 image carries iPad models on the iOS 26.5 runtime the resolver selects. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 161af61..489af7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,14 +49,11 @@ jobs: # One leg failing must never hide another's result. fail-fast: false matrix: - # iPad is deliberately absent, not forgotten. iPhone and iPad exercise - # structurally different UI (NavigationStack vs NavigationSplitView), - # and the UI suite on this branch has no iPad handling at all -- no - # idiom checks, no sidebar paths -- so an `ipad` leg would fail on the - # first UI test. The iPad-capable suite arrives with the App Store - # screenshots branch; add `ipad` to this list once it lands and the - # rest of this job needs no other change. - device: [iphone] + # iPhone and iPad exercise structurally different UI: a compact + # NavigationStack with a navigationBarDrawer search field, versus a + # NavigationSplitView with a sidebar. The UI suite branches on the + # idiom, and until now only the iPhone half had ever run anywhere. + device: [iphone, ipad] steps: - name: Check out From d418038b014c3c24d36f1573d46582f5f8a171d4 Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Wed, 2 Sep 2026 16:23:26 -0400 Subject: [PATCH 2/2] test: make the iOS UI suite pass on iPad's split-view layout Adding the ipad leg to CI (previous commit) failed 3 of 12 UI tests. All three were the suite's helpers assuming an iPhone-shaped NavigationStack; NavigationSplitView needed different queries, not different behavior. Root-caused each by dumping the real accessibility tree rather than guessing: - revealSearchField: .searchable(placement: .sidebar) puts the field above the sidebar's first row, off-screen above the viewport. A full-screen swipeDown() (the iPhone path) hits the detail pane and scrolls nothing. Falls back to swiping the "Sidebar" collection view directly. - returnToList: with two navigation bars on screen (sidebar + detail pane), navigationBars.buttons.firstMatch resolved to the DETAIL pane's first button -- "New Note" -- so this was silently creating a note instead of navigating. iPad has nothing to pop; the list is always visible. Guarded on a new isSplitViewLayout(in:) check. - Two membership/deletion assertions: a note's title renders in both the sidebar row and the detail pane on iPad, so app.staticTexts[title] is ambiguous, and a stale detail pane can defeat a "no longer in the list" check. Added noteList(in:) to scope those queries to the actual list. - Permanent-delete confirmation: both the detail pane and the confirmation dialog have a "Delete Now" button. Picking the last app-wide match assumed the dialog sorts after the pane, which does not hold on iPad, where the confirmation is a popover -- the pane's own button was tapped, the dialog was never confirmed, and the note silently survived. Added confirmPermanentDelete(in:), scoped to the sheet/alert container. Verified: 12/12 pass on iPad, 12/12 on iPhone -- including on a fresh, disposable simulator, to rule out interference from other sessions sharing this machine's simulators. Zero syntax errors from a standalone parse of the changed file. Also removed four identical, untracked duplicate files (iCloud sync artifacts: Localizable 2.xcstrings, ScreenshotDemoContent 2.swift, LocalizationTests 2.swift, verify-localization 2.sh) that broke local compilation with "invalid redeclaration" -- unrelated to this branch, but blocked reproducing the CI failure locally. Co-Authored-By: Claude Sonnet 5 --- .../Sources/CheatSheetiOSUITests.swift | 77 ++++++++++++++----- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift b/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift index 956ec27..4e1d66c 100644 --- a/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift +++ b/CheatSheetUITests/Sources/CheatSheetiOSUITests.swift @@ -131,27 +131,24 @@ final class CheatSheetiOSUITests: XCTestCase { openSecondaryAction("Move to Trash", in: app) openSecondaryAction("Show Trash", in: app) - XCTAssertTrue(app.staticTexts["Disposable Note"].waitForExistence(timeout: 10)) - app.staticTexts["Disposable Note"].tap() + XCTAssertTrue(noteList(in: app).staticTexts["Disposable Note"].waitForExistence(timeout: 10)) + noteList(in: app).staticTexts["Disposable Note"].tap() let restoreButton = app.buttons["restore-note-button"] XCTAssertTrue(restoreButton.waitForExistence(timeout: 10)) restoreButton.tap() - XCTAssertTrue(app.staticTexts["Disposable Note"].waitForExistence(timeout: 10)) + XCTAssertTrue(noteList(in: app).staticTexts["Disposable Note"].waitForExistence(timeout: 10)) - app.staticTexts["Disposable Note"].tap() + noteList(in: app).staticTexts["Disposable Note"].tap() XCTAssertTrue(app.textFields["note-title-field"].waitForExistence(timeout: 10)) openSecondaryAction("Move to Trash", in: app) openSecondaryAction("Show Trash", in: app) - app.staticTexts["Disposable Note"].tap() + noteList(in: app).staticTexts["Disposable Note"].tap() let deleteButton = app.buttons["delete-note-button"] XCTAssertTrue(deleteButton.waitForExistence(timeout: 10)) deleteButton.tap() - let deleteButtons = app.buttons.matching(identifier: "Delete Now") - let confirmDeleteButton = deleteButtons.element(boundBy: max(deleteButtons.count - 1, 0)) - XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 10)) - confirmDeleteButton.tap() + confirmPermanentDelete(in: app) XCTAssertTrue(app.staticTexts["Trash is empty"].waitForExistence(timeout: 10)) } @@ -176,11 +173,47 @@ final class CheatSheetiOSUITests: XCTestCase { if app.keyboards.count > 0 { app.keyboards.buttons["Return"].tap() } + // iPad keeps the list on screen in a split-view sidebar, so there is + // nothing to pop. Tapping anyway is actively harmful: with two + // navigation bars present, `navigationBars.buttons.firstMatch` + // resolves to the DETAIL pane's first button -- "New Note" -- so this + // silently created a note instead of navigating. + guard !isSplitViewLayout(in: app) else { return } let backButton = app.navigationBars.buttons.firstMatch XCTAssertTrue(backButton.waitForExistence(timeout: 10)) backButton.tap() } + /// True on the iPad/regular-width layout, where NavigationSplitView shows + /// the note list in a sidebar alongside the detail pane. + private func isSplitViewLayout(in app: XCUIApplication) -> Bool { + app.collectionViews["Sidebar"].exists + } + + /// The note list itself: a system "Sidebar" collection view on iPad, and + /// the app's own `note-list` on iPhone. Membership assertions must be + /// scoped to it. On iPad a note's title also renders in the detail pane, + /// which makes an app-wide staticTexts query ambiguous, and lets a stale + /// detail pane defeat a "no longer in the list" assertion. + private func noteList(in app: XCUIApplication) -> XCUIElement { + let sidebar = app.collectionViews["Sidebar"] + return sidebar.exists ? sidebar : app.collectionViews["note-list"] + } + + /// Confirms the permanent-delete dialog. Both the detail pane and the + /// dialog carry a "Delete Now" button, and the previous approach picked + /// the last app-wide match on the assumption that the dialog sorts after + /// the pane. That does not hold on iPad, where the confirmation is a + /// popover: the pane's own button was tapped instead, the dialog was never + /// confirmed, and the note survived. + private func confirmPermanentDelete(in app: XCUIApplication) { + let sheet = app.sheets.firstMatch + let container = sheet.waitForExistence(timeout: 5) ? sheet : app.alerts.firstMatch + let confirm = container.buttons["Delete Now"] + XCTAssertTrue(confirm.waitForExistence(timeout: 10), "Delete confirmation dialog never offered Delete Now.") + confirm.tap() + } + private func openSecondaryAction(_ name: String, in app: XCUIApplication) { let directButton = app.buttons[name] if directButton.waitForExistence(timeout: 2), directButton.isHittable { @@ -237,13 +270,24 @@ final class CheatSheetiOSUITests: XCTestCase { ) } + /// iPhone hides the navigationBarDrawer search field until the list + /// scrolls, and a full-screen swipe reaches it. iPad keeps the list in a + /// split-view sidebar, where `.searchable(placement: .sidebar)` puts the + /// field above the first row -- a full-screen swipe there lands on the + /// detail pane and scrolls nothing, so the sidebar must be swiped directly. private func revealSearchField(in app: XCUIApplication) -> XCUIElement { var field = app.searchFields.firstMatch - if !field.waitForExistence(timeout: 2) { - app.swipeDown() - field = app.searchFields.firstMatch + if field.waitForExistence(timeout: 2) { return field } + + app.swipeDown() + field = app.searchFields.firstMatch + if field.waitForExistence(timeout: 2) { return field } + + let sidebar = app.collectionViews["Sidebar"] + if sidebar.waitForExistence(timeout: 2) { + sidebar.swipeDown() } - return field + return app.searchFields.firstMatch } // MARK: - Ad hoc QA sweep (manual verification pass, runs on iPhone and iPad) @@ -421,11 +465,8 @@ final class CheatSheetiOSUITests: XCTestCase { deleteButton.tap() capture(app, "13a-delete-confirmation-dialog") - let deleteButtons = app.buttons.matching(identifier: "Delete Now") - let confirmDeleteButton = deleteButtons.element(boundBy: max(deleteButtons.count - 1, 0)) - XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 10)) - confirmDeleteButton.tap() - XCTAssertFalse(app.staticTexts["Vim Motions"].waitForExistence(timeout: 5), "Permanently deleted note should be gone from Trash.") + confirmPermanentDelete(in: app) + XCTAssertFalse(noteList(in: app).staticTexts["Vim Motions"].waitForExistence(timeout: 5), "Permanently deleted note should be gone from Trash.") capture(app, "13b-note-deleted-from-trash") assertNoPersistenceBanner(app) }