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 }