A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns. With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
259 lines
12 KiB
Swift
259 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 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
|
|
}
|