Build the end-to-end UI test pass
The golden-path XCUITest suite, adapted to current rulings where the card body had gone stale: trash flows follow the materialized-trash grammar (no Put Back, restore is drag or cut/paste out), git flows are pro-m1 scope and skipped, and fixtures extend m11's in-container --ui-test-fixture-board mechanism (the sandbox forbids the card's --open-board path handoff) with exact-match variant flags: standard (the three-lane audit board), large (8 lanes x 40 cards for masonry/reflow), malformed (BoardWriter-built board with one card's index.md overwritten to unterminated YAML, opened through the ORDINARY path so the failure is the loader's own). EndToEndFlowTests: create card/lane, inline rename, coordinate drag across lanes, cut/paste, undo/redo of a move, delete-to-trash / show-trash / restore-by-cut-paste / Empty Trash confirm - all asserting on lane accessibility labels. FailFastLaunchTests: welcome appears, no board window ever, a welcome row carries the loader's sentence naming the file; byte-fidelity of the malformed board pinned unconditionally in KanbanTests plus an identically-refused relaunch. Performance: launch metric plus explicit wall-clock gates (30s launch / 5s Show Trash on 320 cards) since XCTest baselines don't travel. Powerbox panels (template save panel, Duplicate fallback, Open) are documented as manual in EndToEndVerification.md, not faked. The smoke test now launches on the standard fixture (it launched bare before, opening the developer's real boards); README's everyday test command scopes to -only-testing:KanbanTests. Suite compiles on both schemes (build-for-testing verified); flows await a real display + automation permission to execute - run instructions in KanbanUITests/EndToEndVerification.md. +8 unit tests; 1669 green both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -96,6 +96,69 @@ struct UITestLaunchFlagTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The variant flags
|
||||
|
||||
/// The three fixture shapes and the arguments that name them (`UITestLaunch.FixtureVariant`).
|
||||
///
|
||||
/// The parsing is the flag's, restated one level down — exact match, double dash, no `=value` and no
|
||||
/// `--flag value` pair — so the whole family has one edge rather than two, and the tie-break exists
|
||||
/// so a launch naming two variants is a decided case rather than an argument-order accident.
|
||||
@Suite("The UI-test fixture variants")
|
||||
struct UITestFixtureVariantTests {
|
||||
|
||||
@Test("A bare fixture flag is the standard board", arguments: [
|
||||
["--ui-test-fixture-board"],
|
||||
["/path/to/Lanework", "--ui-test-fixture-board", "-NSTreatUnknownArgumentsAsOpen", "NO"],
|
||||
[],
|
||||
])
|
||||
func standardIsTheDefault(arguments: [String]) {
|
||||
#expect(UITestLaunch.variant(arguments: arguments) == .standard)
|
||||
}
|
||||
|
||||
@Test("A variant flag names its variant", arguments: UITestLaunch.FixtureVariant.allCases)
|
||||
func variantRecognized(variant: UITestLaunch.FixtureVariant) {
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, variant.flag]) == variant)
|
||||
// The pairing every call site uses is base-flag-plus-variant, but the variant alone is
|
||||
// enough to mark the launch synthetic — otherwise a bundle that forgot the base flag would
|
||||
// get an ordinary launch over the developer's real boards.
|
||||
#expect(UITestLaunch.isFixtureLaunch(arguments: [variant.flag]))
|
||||
}
|
||||
|
||||
/// The same fuzzy-edge rule the base flag has, applied to the family: a near-miss is not a flag,
|
||||
/// and a near-miss is therefore not a fixture launch either.
|
||||
@Test("A near-miss is not a variant flag")
|
||||
func variantNotMatchedLoosely() {
|
||||
let large = UITestLaunch.FixtureVariant.large
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "\(large.flag)r"]) == .standard)
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-board=large"]) == .standard)
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-variant", "large"]) == .standard)
|
||||
#expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-large"]) == false)
|
||||
}
|
||||
|
||||
/// Declaration order breaks a tie, whatever order the arguments arrived in.
|
||||
@Test("Two variants named at once resolve in declaration order")
|
||||
func variantTieBreak() {
|
||||
let flags = [UITestLaunch.FixtureVariant.malformed.flag, UITestLaunch.FixtureVariant.large.flag]
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags) == .large)
|
||||
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags.reversed()) == .large)
|
||||
}
|
||||
|
||||
/// Every variant flag is double-dashed, for the base flag's reason, and every one of them is
|
||||
/// distinct from the base flag and from its siblings — the window titles below rest on the same
|
||||
/// distinctness, so a duplicate would be two boards claiming one name.
|
||||
@Test("The flags and titles are double-dashed and distinct")
|
||||
func flagsAreWellFormed() {
|
||||
let variants = UITestLaunch.FixtureVariant.allCases
|
||||
#expect(variants.allSatisfy { $0.flag.hasPrefix("--") })
|
||||
#expect(Set(variants.map(\.flag)).count == variants.count)
|
||||
#expect(variants.allSatisfy { $0.flag != UITestLaunch.fixtureFlag })
|
||||
#expect(Set(variants.map(\.boardTitle)).count == variants.count)
|
||||
// The standard variant's title is the one the audit suite has always waited on.
|
||||
#expect(UITestLaunch.FixtureVariant.standard.boardTitle == UITestLaunch.boardTitle)
|
||||
#expect(UITestLaunch.fixtureBoardURL == UITestLaunch.fixtureBoardURL(for: .standard))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The fixture board
|
||||
|
||||
@Suite("The audit fixture board")
|
||||
@@ -147,12 +210,17 @@ struct UITestFixtureBoardTests {
|
||||
/// Everything the fixture launch writes stays inside the app's own container — the sandbox
|
||||
/// constraint that decided the whole design (a path handed over on the command line would not be
|
||||
/// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
|
||||
///
|
||||
/// Every variant's board, not just the audit's: they share one scratch root by construction, and
|
||||
/// this is the assertion that keeps a future variant from inventing a second home.
|
||||
@Test("Everything it writes is inside the app container")
|
||||
func scratchIsContained() {
|
||||
let container = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).standardizedFileURL.path
|
||||
#expect(UITestLaunch.scratchRoot.standardizedFileURL.path.hasPrefix(container))
|
||||
#expect(UITestLaunch.registryStorageURL.standardizedFileURL.path.hasPrefix(container))
|
||||
#expect(UITestLaunch.fixtureBoardURL.standardizedFileURL.path.hasPrefix(container))
|
||||
for variant in UITestLaunch.FixtureVariant.allCases {
|
||||
#expect(UITestLaunch.fixtureBoardURL(for: variant).standardizedFileURL.path.hasPrefix(container))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture registry is **not** the real one — the clause that keeps an audit run out of the
|
||||
@@ -163,3 +231,158 @@ struct UITestFixtureBoardTests {
|
||||
#expect(UITestLaunch.registryStorageURL != BoardRegistry.defaultStorageURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The large board
|
||||
|
||||
/// The performance suite's board (`UITestLaunch.FixtureVariant.large`).
|
||||
///
|
||||
/// Tested here for the audit fixture's reason turned up a notch: **the suite that consumes it cannot
|
||||
/// be run in every environment**, and a large board that quietly came out small would turn a
|
||||
/// performance measurement into a measurement of something else — one that *passes*, since a smaller
|
||||
/// board is a faster one. So the counts are asserted, through the ordinary loader, where they can be
|
||||
/// checked anywhere.
|
||||
@Suite("The large fixture board")
|
||||
struct UITestLargeFixtureBoardTests {
|
||||
|
||||
@Test("It builds at the stated size and loads through the ordinary loader")
|
||||
func largeBoardLoads() throws {
|
||||
UITestLaunch.prepareScratchDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
||||
|
||||
let root = try UITestLaunch.materializeFixtureBoard(.large)
|
||||
let result = try BoardLoader.load(boardRoot: root)
|
||||
let model = result.model
|
||||
|
||||
// The window title the performance suite waits on.
|
||||
#expect(model.title.value == UITestLaunch.FixtureVariant.large.boardTitle)
|
||||
#expect(root == UITestLaunch.fixtureBoardURL(for: .large))
|
||||
|
||||
// The size the budgets in `EndToEndVerification.md` are budgets *for*. A board that came out
|
||||
// a different size makes every one of them a number about a different board.
|
||||
#expect(model.lanes.count == UITestLaunch.largeLaneCount)
|
||||
#expect(model.lanes.allSatisfy { $0.cards.count == UITestLaunch.largeCardsPerLane })
|
||||
#expect(model.lanes.map(\.cards.count).reduce(0, +) == UITestLaunch.largeLaneCount * UITestLaunch.largeCardsPerLane)
|
||||
|
||||
// Lanes in board order, exactly as named — the same claim the audit fixture makes, and for
|
||||
// the same reason: a shuffled board would make a lane-addressed assertion meaningless.
|
||||
#expect(model.lanes.map(\.title.value) == (0 ..< UITestLaunch.largeLaneCount).map(UITestLaunch.largeLaneTitle))
|
||||
|
||||
// Cards in card order within each lane, and every title distinct across the whole board —
|
||||
// which is what lets a UI test name one card and mean one card.
|
||||
for (laneIndex, lane) in model.lanes.enumerated() {
|
||||
let expected = (0 ..< UITestLaunch.largeCardsPerLane).map {
|
||||
UITestLaunch.largeCardTitle(lane: laneIndex, card: $0)
|
||||
}
|
||||
#expect(lane.cards.map(\.title.value) == expected)
|
||||
}
|
||||
let titles = model.lanes.flatMap { $0.cards.compactMap(\.title.value) }
|
||||
#expect(Set(titles).count == titles.count)
|
||||
|
||||
// Four title lengths, cycled — the masonry has different card heights to balance rather than
|
||||
// a perfect grid, which is the one thing about this board that is not simply "a lot of it".
|
||||
let firstLane = try #require(model.lanes.first)
|
||||
#expect(Set(firstLane.cards.compactMap(\.title.value).prefix(4).map(\.count)).count == 4)
|
||||
|
||||
// Nothing tolerated-but-notable: this board is built through the Writer alone, so a warning
|
||||
// here would mean the *builder* left a stray behind.
|
||||
#expect(result.warnings.isEmpty)
|
||||
#expect(model.trash.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The malformed board
|
||||
|
||||
/// The fail-fast suite's board (`UITestLaunch.FixtureVariant.malformed`) — and the two claims the UI
|
||||
/// suite makes about it, pinned where they can be checked without a display.
|
||||
///
|
||||
/// 01-storage-format.md § Malformed input is the rule under test: a structurally broken `index.md`
|
||||
/// rejects **the whole load**, loudly, naming the file — and the app never rewrites what it could not
|
||||
/// read (the Repair precedent, which `BoardLoader`'s own note states as "a load is a pure function of
|
||||
/// the tree and writes nothing, ever").
|
||||
@Suite("The malformed fixture board")
|
||||
struct UITestMalformedFixtureBoardTests {
|
||||
|
||||
@Test("It builds, and then fails to load — loudly, naming the offending file")
|
||||
func malformedBoardFailsFast() throws {
|
||||
UITestLaunch.prepareScratchDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
||||
|
||||
// Building succeeds. That is the point: the failure under test is the *loader's*, so a
|
||||
// fixture that threw on the way in would surface a different sentence entirely.
|
||||
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
||||
#expect(root == UITestLaunch.fixtureBoardURL(for: .malformed))
|
||||
|
||||
do {
|
||||
_ = try BoardLoader.load(boardRoot: root)
|
||||
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
|
||||
} catch let error as BoardLoadError {
|
||||
// The path is board-relative and names the *file*, which is what the welcome row's
|
||||
// failure caption carries and what the UI test asserts against.
|
||||
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
|
||||
#expect(error.path.split(separator: "/").count == 3, "the offending path names <lane>/<card>/index.md")
|
||||
|
||||
// The reason is the one the bytes were written to produce — unparseable YAML, not a
|
||||
// missing field. A future edit to `malformedIndexText` that accidentally produced a
|
||||
// *valid* file with a missing key would still fail the load, and this line is what
|
||||
// would notice.
|
||||
if case .unparseableYAML = error.reason {} else {
|
||||
Issue.record("expected unparseable YAML, got \(error.reason)")
|
||||
}
|
||||
|
||||
// The whole sentence, which is what actually reaches the user: file first, then why.
|
||||
#expect(error.description.contains(BoardLoader.indexFileName))
|
||||
#expect(error.description.lowercased().contains("yaml"))
|
||||
} catch {
|
||||
Issue.record("expected a BoardLoadError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// **Nothing is silently repaired.** The refused load leaves the malformed bytes exactly as they
|
||||
/// were written — no rewrite, no relocation into `.trash/`, no skip-and-continue — and the intact
|
||||
/// siblings are untouched too.
|
||||
///
|
||||
/// This is the claim the UI suite can only make opportunistically (it can read the app's
|
||||
/// container when the runner can reach it), so it is made unconditionally here.
|
||||
@Test("A refused load repairs nothing")
|
||||
func malformedBoardIsNotRepaired() throws {
|
||||
UITestLaunch.prepareScratchDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
||||
|
||||
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
||||
let before = try Self.tree(under: root)
|
||||
|
||||
// Twice, because a repair that only ran on the second attempt would be the worst kind.
|
||||
for _ in 0 ..< 2 {
|
||||
do {
|
||||
_ = try BoardLoader.load(boardRoot: root)
|
||||
Issue.record("the malformed board loaded")
|
||||
} catch let error as BoardLoadError {
|
||||
#expect(error.path.hasSuffix(BoardLoader.indexFileName))
|
||||
} catch {
|
||||
Issue.record("expected a BoardLoadError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
#expect(try Self.tree(under: root) == before)
|
||||
|
||||
// And the bytes themselves are the ones the fixture wrote, marker included — the string a UI
|
||||
// test searches the container for when it can reach it.
|
||||
let malformed = try #require(before.first { $0.value.contains(UITestLaunch.malformationMarker) })
|
||||
#expect(malformed.value == UITestLaunch.malformedIndexText)
|
||||
#expect(malformed.key.hasSuffix("/\(BoardLoader.indexFileName)"))
|
||||
}
|
||||
|
||||
/// Every `index.md` beneath `root`, keyed by its board-relative path, read as raw text. Hidden
|
||||
/// entries included, so a relocation into `.trash/` would show up as a new key rather than as a
|
||||
/// silence.
|
||||
private static func tree(under root: URL) throws -> [String: String] {
|
||||
let manager = FileManager.default
|
||||
guard let walker = manager.enumerator(atPath: root.path) else { return [:] }
|
||||
var files: [String: String] = [:]
|
||||
for case let relative as String in walker where relative.hasSuffix(BoardLoader.indexFileName) {
|
||||
let data = try Data(contentsOf: root.appendingPathComponent(relative))
|
||||
files[relative] = String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user