Files
lanework/KanbanMobileUITests/MobileUITestSupport.swift
T
rzen ce8a446658 The phone gets its first witnesses — KanbanMobileUITests drive the MVP against a fixture board
The mobile app's first runtime coverage, and its first runtime, full stop: the suite launches the real app over a scratch directory seeded from rich-board.kanban via LANEWORK_LOCAL_ROOT, walks boards to lanes to cards to the card editor, retitles a card and polls the frontmatter on disk until the write lands, then taps through the Settings About section to see the changelog and license actually render. No iCloud account, no metadata query — the DEBUG override is the whole harness.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 10:22:11 -04:00

126 lines
5.9 KiB
Swift

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
}