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
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Board list → lanes → cards → card detail, then an edit that has to reach disk — the one
|
||||||
|
/// end-to-end walk of the mobile MVP's navigation stack (`BoardRoute`'s three destinations).
|
||||||
|
final class BoardsNavigationUITests: XCTestCase {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testBoardsNavigationAndTitleEdit() throws {
|
||||||
|
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
|
||||||
|
|
||||||
|
// Boards is the first tab (`RootTabView`), so no tab switch is needed here. The row's
|
||||||
|
// label is the flattened `BoardSummaryRow` — title plus the "N lanes · N cards · modified"
|
||||||
|
// subtitle `BoardIndexStore`'s first scan fills in.
|
||||||
|
let boardRow = app.element(labelContaining: RichBoard.title)
|
||||||
|
XCTAssertTrue(
|
||||||
|
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
|
||||||
|
)
|
||||||
|
boardRow.tap()
|
||||||
|
|
||||||
|
let laneRow = app.element(labelContaining: RichBoard.firstLane)
|
||||||
|
XCTAssertTrue(
|
||||||
|
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the \"\(RichBoard.firstLane)\" lane row never appeared"
|
||||||
|
)
|
||||||
|
laneRow.tap()
|
||||||
|
|
||||||
|
let cardRow = app.element(labelContaining: RichBoard.firstLaneFirstCard)
|
||||||
|
XCTAssertTrue(
|
||||||
|
cardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
|
||||||
|
)
|
||||||
|
cardRow.tap()
|
||||||
|
|
||||||
|
// CardDetailScreen: the only `textField` on this screen is the title
|
||||||
|
// (`Section("Title")`) — the body is a `TextEditor`, which is a `textView`.
|
||||||
|
let titleField = app.textFields.firstMatch
|
||||||
|
XCTAssertTrue(
|
||||||
|
titleField.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"CardDetailScreen's title field never appeared"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
titleField.value as? String, RichBoard.firstLaneFirstCard,
|
||||||
|
"the title field did not seed with the fixture card's own title"
|
||||||
|
)
|
||||||
|
|
||||||
|
let sentinel = "Retitled by UI test"
|
||||||
|
titleField.tap()
|
||||||
|
// Select-all via a hardware-keyboard shortcut (iOS answers ⌘A the same as macOS when a
|
||||||
|
// keyboard is attached, which the Simulator always presents one as) rather than a
|
||||||
|
// backspace-per-character workaround, whose delete count only works if the tap happened
|
||||||
|
// to land the cursor at the end of the existing text.
|
||||||
|
titleField.typeKey("a", modifierFlags: .command)
|
||||||
|
titleField.typeText(sentinel)
|
||||||
|
|
||||||
|
// Commit the way the screen commits (`CardDetailScreen.body`'s `.confirmationAction`):
|
||||||
|
// the Done button folds `commitAll()` in and drops focus, without navigating back.
|
||||||
|
app.navigationBars.buttons["Done"].tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForFile(under: root, containing: sentinel),
|
||||||
|
"the retitled card never landed on disk under \(root.path)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// The Settings tab's last section — `SettingsTabView`'s `IndieAbout` block: version/build (which
|
||||||
|
/// doubles as the changelog link), the License document link, and the copyright line.
|
||||||
|
final class SettingsAboutUITests: XCTestCase {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSettingsAboutSection() throws {
|
||||||
|
let (app, _) = XCUIApplication.launchedWithFixtureBoard()
|
||||||
|
|
||||||
|
app.tabBars.buttons["Settings"].tap()
|
||||||
|
|
||||||
|
// The About section is last in the form (`SettingsTabView`'s own comment: "Last section
|
||||||
|
// by convention"), below the Backup section — a couple of speculative swipes settle the
|
||||||
|
// list near the bottom before anything here is queried, harmless if the content already
|
||||||
|
// fit on screen.
|
||||||
|
app.swipeUp()
|
||||||
|
app.swipeUp()
|
||||||
|
|
||||||
|
// `IndieAbout`'s version line is three sibling `Text` views (prefix, the tappable
|
||||||
|
// "1.0-<build>" link, suffix) — none of them wrapped in a control that would flatten them
|
||||||
|
// into one accessibility element, so the link is addressable on its own by the substring
|
||||||
|
// only it carries. `MARKETING_VERSION` for KanbanMobile is "1.0" (project.yml).
|
||||||
|
let versionLink = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "1.0")).firstMatch
|
||||||
|
XCTAssertTrue(
|
||||||
|
versionLink.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"no version label containing \"1.0\" appeared in Settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
let licenseLink = app.staticTexts["License"]
|
||||||
|
XCTAssertTrue(licenseLink.waitForExistence(timeout: XCUIApplication.uiTimeout), "the License link never appeared")
|
||||||
|
|
||||||
|
let copyright = app.element(labelContaining: "2026 rzen")
|
||||||
|
XCTAssertTrue(copyright.waitForExistence(timeout: XCUIApplication.uiTimeout), "the copyright text never appeared")
|
||||||
|
|
||||||
|
// Tap the version link — opens the changelog (`IndieAbout.versionLineView`'s
|
||||||
|
// `.onTapGesture`, wired to `changelogDocument: .changelog()` in `SettingsTabView`).
|
||||||
|
app.scrollUntilHittable(versionLink)
|
||||||
|
versionLink.tap()
|
||||||
|
let changelogText = app.element(labelContaining: "August 2026")
|
||||||
|
XCTAssertTrue(
|
||||||
|
changelogText.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the changelog sheet never showed \"August 2026\" (KanbanMobile/CHANGELOG.md)"
|
||||||
|
)
|
||||||
|
app.navigationBars.buttons["Done"].tap()
|
||||||
|
|
||||||
|
// Tap License — opens the bundled ISC license.
|
||||||
|
app.scrollUntilHittable(licenseLink)
|
||||||
|
licenseLink.tap()
|
||||||
|
let licenseText = app.element(labelContaining: "ISC License")
|
||||||
|
XCTAssertTrue(
|
||||||
|
licenseText.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the license sheet never showed \"ISC License\" (LICENSE.md)"
|
||||||
|
)
|
||||||
|
app.navigationBars.buttons["Done"].tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -288,6 +288,31 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: true
|
GENERATE_INFOPLIST_FILE: true
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
|
||||||
|
# MARK: - iPhone UI tests
|
||||||
|
#
|
||||||
|
# The mobile app's first runtime coverage: launches the real app against a plain directory
|
||||||
|
# (`LANEWORK_LOCAL_ROOT`, `CloudHome`'s DEBUG override) seeded from a copy of
|
||||||
|
# `Fixtures/Valid/rich-board.kanban`, so these tests need no iCloud account and touch nothing but
|
||||||
|
# a scratch directory the test itself creates and owns. `Fixtures/` rides in as a folder reference
|
||||||
|
# for the same reason `KanbanTests`' copy does — a fixture is a board, and a board is directories
|
||||||
|
# on disk.
|
||||||
|
|
||||||
|
KanbanMobileUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- KanbanMobileUITests
|
||||||
|
- path: Fixtures
|
||||||
|
type: folder
|
||||||
|
buildPhase: resources
|
||||||
|
dependencies:
|
||||||
|
- target: KanbanMobile
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanMobileUITests
|
||||||
|
GENERATE_INFOPLIST_FILE: true
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
# `xcodebuild … -scheme Kanban` is the established command for this repo, and it now means the
|
# `xcodebuild … -scheme Kanban` is the established command for this repo, and it now means the
|
||||||
# only app there is.
|
# only app there is.
|
||||||
@@ -318,3 +343,15 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
archive:
|
archive:
|
||||||
config: Release
|
config: Release
|
||||||
|
|
||||||
|
# The phone app's own scheme — otherwise auto-generated, declared here only because it needs a
|
||||||
|
# test action pointed at `KanbanMobileUITests` rather than xcodegen's per-target default.
|
||||||
|
KanbanMobile:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
KanbanMobile: all
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
gatherCoverageData: false
|
||||||
|
targets:
|
||||||
|
- KanbanMobileUITests
|
||||||
|
|||||||
Reference in New Issue
Block a user