Files
lanework/KanbanMobileUITests/MobileUITestSupport.swift
T
rzen 871083e5ca The tab bar retires — boards are the app, settings behind the gear, recency the default order
The boards stack becomes the root screen: Settings moves from its own
tab to a sheet behind a leading gearshape button, the sort pickers trade
their text segments for symbols (hand.draw, textformat.abc, clock) with
the words kept for accessibility, and every list now opens sorted by
most recent change. UI tests re-aimed at the gear button and Done-button
scoping fixed for the sheet-over-sheet nav bars — 4/4 green in the
simulator.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 13:07:44 -04:00

260 lines
12 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"
/// 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 boards screen's own nav bar carries a
/// "Settings" button of its own — the leading gear button that presents `SettingsScreen` as a
/// sheet, 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
}