diff --git a/KanbanMobileUITests/BoardsNavigationUITests.swift b/KanbanMobileUITests/BoardsNavigationUITests.swift new file mode 100644 index 0000000..c247777 --- /dev/null +++ b/KanbanMobileUITests/BoardsNavigationUITests.swift @@ -0,0 +1,65 @@ +import XCTest + +/// Board list → lanes → cards → card detail, then an edit that has to reach disk — the one +/// end-to-end walk of the mobile MVP's navigation stack (`BoardRoute`'s three destinations). +final class BoardsNavigationUITests: XCTestCase { + + @MainActor + func testBoardsNavigationAndTitleEdit() throws { + let (app, root) = XCUIApplication.launchedWithFixtureBoard() + + // Boards is the first tab (`RootTabView`), so no tab switch is needed here. The row's + // label is the flattened `BoardSummaryRow` — title plus the "N lanes · N cards · modified" + // subtitle `BoardIndexStore`'s first scan fills in. + let boardRow = app.element(labelContaining: RichBoard.title) + XCTAssertTrue( + boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan" + ) + boardRow.tap() + + let laneRow = app.element(labelContaining: RichBoard.firstLane) + XCTAssertTrue( + laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the \"\(RichBoard.firstLane)\" lane row never appeared" + ) + laneRow.tap() + + let cardRow = app.element(labelContaining: RichBoard.firstLaneFirstCard) + XCTAssertTrue( + cardRow.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared" + ) + cardRow.tap() + + // CardDetailScreen: the only `textField` on this screen is the title + // (`Section("Title")`) — the body is a `TextEditor`, which is a `textView`. + let titleField = app.textFields.firstMatch + XCTAssertTrue( + titleField.waitForExistence(timeout: XCUIApplication.uiTimeout), + "CardDetailScreen's title field never appeared" + ) + XCTAssertEqual( + titleField.value as? String, RichBoard.firstLaneFirstCard, + "the title field did not seed with the fixture card's own title" + ) + + let sentinel = "Retitled by UI test" + titleField.tap() + // Select-all via a hardware-keyboard shortcut (iOS answers ⌘A the same as macOS when a + // keyboard is attached, which the Simulator always presents one as) rather than a + // backspace-per-character workaround, whose delete count only works if the tap happened + // to land the cursor at the end of the existing text. + titleField.typeKey("a", modifierFlags: .command) + titleField.typeText(sentinel) + + // Commit the way the screen commits (`CardDetailScreen.body`'s `.confirmationAction`): + // the Done button folds `commitAll()` in and drops focus, without navigating back. + app.navigationBars.buttons["Done"].tap() + + XCTAssertTrue( + waitForFile(under: root, containing: sentinel), + "the retitled card never landed on disk under \(root.path)" + ) + } +} diff --git a/KanbanMobileUITests/MobileUITestSupport.swift b/KanbanMobileUITests/MobileUITestSupport.swift new file mode 100644 index 0000000..1ed5434 --- /dev/null +++ b/KanbanMobileUITests/MobileUITestSupport.swift @@ -0,0 +1,125 @@ +import XCTest + +// MARK: - The fixture board + +/// A tiny anchor class purely so `Bundle(for:)` can find this test bundle — there is no +/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience). Same trick +/// `KanbanTests/FixtureBoardTests.swift` uses on the Mac side. +private final class FixtureBundleAnchor {} + +/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim +/// (`project.yml`'s `KanbanMobileUITests` target). Real directories on disk, not synthesized +/// strings — a `.kanban` board is a package, and this test drives the real loader over one. +private func fixturesRoot() -> URL { + guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else { + fatalError("test bundle has no resourceURL") + } + return resources.appendingPathComponent("Fixtures", isDirectory: true) +} + +/// "Rich Demo Board" — 2 lanes, 3 cards, one card per lane's first slot named below. Mirrored from +/// the fixture's own `index.md` files rather than re-derived at runtime, so a test that expects +/// "Doing" to be the first lane is asserting the fixture's own `order:` keys, not guessing them. +enum RichBoard { + static let title = "Rich Demo Board" + static let firstLane = "Doing" + static let firstLaneFirstCard = "Design the fixture taxonomy" +} + +// MARK: - Driving the app + +extension XCUIApplication { + + /// One timeout for the whole suite. Generous on the boards list in particular: its first + /// paint follows `BoardIndexStore`'s first scan, which under `LANEWORK_LOCAL_ROOT` is a + /// directory enumeration rather than a cloud round trip, but still runs off-main behind a + /// `Task.detached` — a test that is slow here is not a test that is wrong. + static let uiTimeout: TimeInterval = 20 + + /// The first element anywhere in the app whose label *contains* `fragment` — the same + /// escape hatch `KanbanUITests/UITestSupport.swift` uses on the Mac side, and for the same + /// reason: a `List` row wrapping a `NavigationLink` is exposed to the accessibility tree as + /// one flattened element (title + subtitle concatenated), and neither piece is promised its + /// own queryable node. + @MainActor + func element(labelContaining fragment: String) -> XCUIElement { + descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@", fragment)) + .firstMatch + } + + /// Launches the app against a fresh scratch directory seeded with a copy of + /// `Fixtures/Valid/rich-board.kanban`, via `LANEWORK_LOCAL_ROOT` — `CloudHome`'s DEBUG + /// override (`CloudHomeResolver.resolveBlocking()`). No `SIMCTL_CHILD_` prefix: XCUITest's + /// `launchEnvironment` already lands in the *app's* process, not the test runner's. + /// + /// Returns the app (not yet launched into any particular screen — the caller drives + /// navigation from the Boards tab) and the scratch root, so a test can poll the board's files + /// on disk after driving an edit through the UI. + @MainActor + static func launchedWithFixtureBoard() -> (app: XCUIApplication, root: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("KanbanMobileUITests-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = fixturesRoot().appendingPathComponent("Valid/rich-board.kanban", isDirectory: true) + let destination = root.appendingPathComponent("rich-board.kanban", isDirectory: true) + try FileManager.default.copyItem(at: source, to: destination) + } catch { + fatalError("could not seed the fixture board into a scratch root: \(error)") + } + + let app = XCUIApplication() + app.launchEnvironment["LANEWORK_LOCAL_ROOT"] = root.path + app.launch() + XCTAssertTrue( + app.wait(for: .runningForeground, timeout: uiTimeout), + "the app did not reach the foreground" + ) + return (app, root) + } + + /// Scrolls the frontmost view upward, a little at a time, until `element` is hittable or + /// `maxAttempts` is exhausted. A `Form`/`List` does not auto-scroll to reveal what a test asks + /// for, and this suite's Settings screen puts the About section — everything + /// `testSettingsAboutSection` addresses — last. + @MainActor + func scrollUntilHittable(_ element: XCUIElement, maxAttempts: Int = 8) { + var attempts = 0 + while !element.isHittable, attempts < maxAttempts { + swipeUp() + attempts += 1 + } + } +} + +// MARK: - Polling the filesystem + +/// Waits up to `timeout` for some file under `root` (searched recursively) to contain +/// `substring`, polling every 0.25s — the shape every "did the write land on disk" assertion in +/// this bundle needs, since a card's write reaches disk asynchronously (`BoardSession.perform`) +/// well after the UI action that triggered it returns. +func waitForFile(under root: URL, containing substring: String, timeout: TimeInterval = 10) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if fileExists(under: root, containing: substring) { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } while Date() < deadline + return fileExists(under: root, containing: substring) +} + +private func fileExists(under root: URL, containing substring: String) -> Bool { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return false } + + for case let url as URL in enumerator { + guard url.lastPathComponent == "index.md", + let contents = try? String(contentsOf: url, encoding: .utf8) + else { continue } + if contents.contains(substring) { return true } + } + return false +} diff --git a/KanbanMobileUITests/SettingsAboutUITests.swift b/KanbanMobileUITests/SettingsAboutUITests.swift new file mode 100644 index 0000000..dd0a054 --- /dev/null +++ b/KanbanMobileUITests/SettingsAboutUITests.swift @@ -0,0 +1,57 @@ +import XCTest + +/// The Settings tab's last section — `SettingsTabView`'s `IndieAbout` block: version/build (which +/// doubles as the changelog link), the License document link, and the copyright line. +final class SettingsAboutUITests: XCTestCase { + + @MainActor + func testSettingsAboutSection() throws { + let (app, _) = XCUIApplication.launchedWithFixtureBoard() + + app.tabBars.buttons["Settings"].tap() + + // The About section is last in the form (`SettingsTabView`'s own comment: "Last section + // by convention"), below the Backup section — a couple of speculative swipes settle the + // list near the bottom before anything here is queried, harmless if the content already + // fit on screen. + app.swipeUp() + app.swipeUp() + + // `IndieAbout`'s version line is three sibling `Text` views (prefix, the tappable + // "1.0-" link, suffix) — none of them wrapped in a control that would flatten them + // into one accessibility element, so the link is addressable on its own by the substring + // only it carries. `MARKETING_VERSION` for KanbanMobile is "1.0" (project.yml). + let versionLink = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "1.0")).firstMatch + XCTAssertTrue( + versionLink.waitForExistence(timeout: XCUIApplication.uiTimeout), + "no version label containing \"1.0\" appeared in Settings" + ) + + let licenseLink = app.staticTexts["License"] + XCTAssertTrue(licenseLink.waitForExistence(timeout: XCUIApplication.uiTimeout), "the License link never appeared") + + let copyright = app.element(labelContaining: "2026 rzen") + XCTAssertTrue(copyright.waitForExistence(timeout: XCUIApplication.uiTimeout), "the copyright text never appeared") + + // Tap the version link — opens the changelog (`IndieAbout.versionLineView`'s + // `.onTapGesture`, wired to `changelogDocument: .changelog()` in `SettingsTabView`). + app.scrollUntilHittable(versionLink) + versionLink.tap() + let changelogText = app.element(labelContaining: "August 2026") + XCTAssertTrue( + changelogText.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the changelog sheet never showed \"August 2026\" (KanbanMobile/CHANGELOG.md)" + ) + app.navigationBars.buttons["Done"].tap() + + // Tap License — opens the bundled ISC license. + app.scrollUntilHittable(licenseLink) + licenseLink.tap() + let licenseText = app.element(labelContaining: "ISC License") + XCTAssertTrue( + licenseText.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the license sheet never showed \"ISC License\" (LICENSE.md)" + ) + app.navigationBars.buttons["Done"].tap() + } +} diff --git a/project.yml b/project.yml index d284e62..bbc108a 100644 --- a/project.yml +++ b/project.yml @@ -288,6 +288,31 @@ targets: GENERATE_INFOPLIST_FILE: true SWIFT_STRICT_CONCURRENCY: complete + # MARK: - iPhone UI tests + # + # The mobile app's first runtime coverage: launches the real app against a plain directory + # (`LANEWORK_LOCAL_ROOT`, `CloudHome`'s DEBUG override) seeded from a copy of + # `Fixtures/Valid/rich-board.kanban`, so these tests need no iCloud account and touch nothing but + # a scratch directory the test itself creates and owns. `Fixtures/` rides in as a folder reference + # for the same reason `KanbanTests`' copy does — a fixture is a board, and a board is directories + # on disk. + + KanbanMobileUITests: + type: bundle.ui-testing + platform: iOS + sources: + - KanbanMobileUITests + - path: Fixtures + type: folder + buildPhase: resources + dependencies: + - target: KanbanMobile + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanMobileUITests + GENERATE_INFOPLIST_FILE: true + SWIFT_STRICT_CONCURRENCY: complete + schemes: # `xcodebuild … -scheme Kanban` is the established command for this repo, and it now means the # only app there is. @@ -318,3 +343,15 @@ schemes: config: Debug archive: config: Release + + # The phone app's own scheme — otherwise auto-generated, declared here only because it needs a + # test action pointed at `KanbanMobileUITests` rather than xcodegen's per-target default. + KanbanMobile: + build: + targets: + KanbanMobile: all + test: + config: Debug + gatherCoverageData: false + targets: + - KanbanMobileUITests