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" /// The package's folder name — the thing a move relocates, and what a disk assertion looks for /// under one root or the other. Not derived from `title`: the fixture's folder and its `title:` /// key deliberately differ (01-storage-format.md § Board naming allows it), and a move preserves /// the folder name rather than re-deriving one. static let packageName = "rich-board.kanban" } // 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 = scratchRoot(seeded: true) return (launched(["LANEWORK_LOCAL_ROOT": root.path]), root) } /// The same launch with a **second** scratch directory behind `LANEWORK_DEVICE_ROOT` /// (`DeviceHomeResolver`), so both of the app's homes are directories this test created and /// owns — which is what makes a move between them assertable on disk. The fixture starts in the /// stand-in cloud root; the device root starts empty. /// /// Overriding the device root matters as much as overriding the cloud one: the real device home /// is the installed app's own `Documents/`, which survives between test runs and between tests. @MainActor static func launchedWithFixtureBoardInCloud() -> (app: XCUIApplication, cloudRoot: URL, deviceRoot: URL) { let cloudRoot = scratchRoot(seeded: true) let deviceRoot = scratchRoot(seeded: false) let app = launched([ "LANEWORK_LOCAL_ROOT": cloudRoot.path, "LANEWORK_DEVICE_ROOT": deviceRoot.path, ]) return (app, cloudRoot, deviceRoot) } /// A launch with **no iCloud at all** and the fixture in the device home — the no-account run, /// which must still list, open and edit boards. /// /// `LANEWORK_FORCE_NO_ICLOUD` rather than simply omitting `LANEWORK_LOCAL_ROOT`: without the /// override the resolver reaches for the real ubiquity container, and a simulator that happens to /// be signed into an account would resolve one — making this test pass or fail on whose machine /// it ran. @MainActor static func launchedWithFixtureBoardOnDevice() -> (app: XCUIApplication, deviceRoot: URL) { let deviceRoot = scratchRoot(seeded: true) let app = launched([ "LANEWORK_FORCE_NO_ICLOUD": "1", "LANEWORK_DEVICE_ROOT": deviceRoot.path, ]) return (app, deviceRoot) } /// A directory no other test shares, optionally holding a copy of the fixture board. @MainActor private static func scratchRoot(seeded: Bool) -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("KanbanMobileUITests-\(UUID().uuidString)", isDirectory: true) do { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) if seeded { let source = fixturesRoot() .appendingPathComponent("Valid/\(RichBoard.packageName)", isDirectory: true) try FileManager.default.copyItem( at: source, to: root.appendingPathComponent(RichBoard.packageName, isDirectory: true) ) } } catch { fatalError("could not seed a scratch root: \(error)") } return root } @MainActor private static func launched(_ environment: [String: String]) -> XCUIApplication { let app = XCUIApplication() for (key, value) in environment { app.launchEnvironment[key] = value } app.launch() XCTAssertTrue( app.wait(for: .runningForeground, timeout: uiTimeout), "the app did not reach the foreground" ) return app } /// The board list row for `title`, optionally narrowed to one that also carries `marker` — /// "Local" being the only marker there is. Both fragments have to be matched on the *same* /// element because the row is one flattened accessibility node (see `element(labelContaining:)`), /// so its label is the whole subtitle line concatenated onto the title. @MainActor func boardRow(_ title: String, alsoContaining marker: String? = nil) -> XCUIElement { var predicate = NSPredicate(format: "label CONTAINS %@", title) if let marker { predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ predicate, NSPredicate(format: "label CONTAINS %@", marker), ]) } return descendants(matching: .any).matching(predicate).firstMatch } /// Swipes a board row open and taps its Settings action. /// /// The label is scoped to the list because the tab bar carries a "Settings" button of its own — /// same word, entirely different destination — and an unscoped `buttons["Settings"]` is a coin /// flip between them. @MainActor func openBoardSettings(for title: String) { let row = boardRow(title) XCTAssertTrue( row.waitForExistence(timeout: Self.uiTimeout), "the \"\(title)\" row was not there to swipe" ) row.swipeLeft() let action = collectionViews.buttons["Settings"] XCTAssertTrue( action.waitForExistence(timeout: Self.uiTimeout), "the row's Settings swipe action never appeared" ) action.tap() XCTAssertTrue( navigationBars["Board Settings"].waitForExistence(timeout: Self.uiTimeout), "the Board Settings sheet never appeared" ) } /// Confirms the move in the `confirmationDialog` the sheet raises. Deliberately labelled with a /// bare verb, which is also what keeps it distinct from the "Move to …" button that raised it. @MainActor func confirmMove() { let confirm = buttons["Move"] XCTAssertTrue( confirm.waitForExistence(timeout: Self.uiTimeout), "the move confirmation dialog never appeared" ) confirm.tap() } /// 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) } /// Waits up to `timeout` for the directory at `url` to exist — or, with `toExist: false`, to be /// gone. The package-shaped counterpart to `waitForFile(under:containing:)`, and what a move /// assertion needs: `relocateBoard` hands the actual transfer to a detached task and answers the UI /// well before either root has settled. func waitForDirectory(at url: URL, toExist shouldExist: Bool = true, timeout: TimeInterval = 15) -> Bool { let deadline = Date().addingTimeInterval(timeout) repeat { if directoryExists(at: url) == shouldExist { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.25)) } while Date() < deadline return directoryExists(at: url) == shouldExist } private func directoryExists(at url: URL) -> Bool { var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) return exists && isDirectory.boolValue } 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 }