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:
2026-07-29 13:40:18 -04:00
parent 71b112d04c
commit f34707e17e
13 changed files with 1627 additions and 133 deletions
+343
View File
@@ -0,0 +1,343 @@
import XCTest
// MARK: - The fixture boards
/// **The three boards the app can build for a UI test**, mirrored from
/// `UITestLaunch.FixtureVariant`.
///
/// It is a *mirror* and not an import because a UI test bundle does not link the app it drives: the
/// two ends of a launch argument are always a pair of literals, and this is the one place this end of
/// the pair is spelled. The app end is `UITestLaunch.FixtureVariant.flag` / `.boardTitle`; a change
/// to either without the matching change here shows up as a launch that opens welcome and a wait that
/// times out, which is the loudest failure available for a string pair.
enum FixtureBoard {
/// The audit board three lanes ("To Do", "Doing", "Done"), six cards, one with a rich Markdown
/// body and an attachment, one already in `.trash/`. The end-to-end flows use it too: every one
/// of them asserts on a lane's spoken card count, and a shape small enough to state in a sentence
/// is a shape those assertions can be read against.
case standard
/// 8 lanes × 40 cards, for the performance pass. Nothing in it is interesting; there is simply a
/// lot of it.
case large
/// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. It
/// is the only variant whose board window is *expected* never to appear.
case malformed
/// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
/// launch is synthetic" means (it is what redirects the registry into the scratch directory), the
/// second is which board.
var launchArguments: [String] {
switch self {
case .standard: ["--ui-test-fixture-board", "--ui-test-fixture-standard"]
case .large: ["--ui-test-fixture-board", "--ui-test-fixture-large"]
case .malformed: ["--ui-test-fixture-board", "--ui-test-fixture-malformed"]
}
}
/// The board's title, which is also its window title (`BoardWindowHost.windowTitle`) and its
/// folder name.
var windowTitle: String {
switch self {
case .standard: "Audit Board"
case .large: "Large Board"
case .malformed: "Malformed Board"
}
}
}
// MARK: - The standard board's shape
/// The audit/end-to-end board as the tests address it: lane titles, card titles, and the labels
/// those produce.
///
/// Mirrored from `UITestLaunch` for `FixtureBoard`'s reason, and stated as *names* rather than
/// scattered string literals so that a test reads as a sentence about a board rather than as a
/// sentence about strings.
enum StandardBoard {
static let toDo = "To Do"
static let doing = "Doing"
static let done = "Done"
/// The first lane's cards, in card order minus `trashed`, which the fixture deleted on the way
/// out, so the lane renders three of these four.
static let firstLaneCards = [
"Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit",
]
/// The card the fixture deleted into `.trash/` the trash column's one occupant at launch.
static let trashed = "Check the trash grammar"
/// The sole card of "Doing" the rich one, and the destination every cross-lane flow moves into.
static let richCard = "Write the smoke script"
/// The sole card of "Done".
static let doneCard = "Wire the launch fixture"
}
// MARK: - Phrases
/// The spoken vocabulary the board's elements wear, mirrored from `AccessibilityPhrases`.
///
/// The board's containers are addressed by these labels and nothing else a lane's element *is*
/// "title, lane, N cards" which makes a lane's card count assertable without any identifier of
/// our own. That is deliberate: the count is the one thing every one of these flows changes, and
/// asserting on the label asserts the visible badge, the spoken count and the layout all at once,
/// because 10-accessibility.md makes them one number.
enum Phrase {
/// "3 cards", "1 card" the app's one plural folding for a card count.
static func cards(_ count: Int) -> String {
"\(count) card\(count == 1 ? "" : "s")"
}
/// A lane container's label: "title, lane, N cards".
static func lane(_ title: String, cards count: Int) -> String {
"\(title), lane, \(cards(count))"
}
/// The trash container's label. Its *value* is `cards(count)`; the label is the bare word.
static let trash = "Trash"
/// The untitled placeholder what a lane created by File New Lane wears until it is renamed.
static let untitled = "Untitled"
/// The inline title editor's placeholders (`NewCardStubView`, `LaneView`, `CardFaceView`).
static let cardTitlePrompt = "Card title"
}
// MARK: - Driving the app
extension XCUIApplication {
/// One timeout for the whole suite. Generous, because a cold launch here also builds a board on
/// disk, and because a UI test that is slow is not a UI test that is wrong.
static let uiTimeout: TimeInterval = 30
/// The first element anywhere in the app carrying `label`, whatever type it turned out to be.
///
/// SwiftUI decides for itself which `XCUIElement.ElementType` a flattened accessibility element
/// lands as a card face and a lane container are both "one element" by design and neither is
/// promised to be a `staticText` so the waits in this file match on the label, which *is*
/// specified (`AccessibilityPhrases`), rather than on a type that is not.
@MainActor
func element(labeled label: String) -> XCUIElement {
descendants(matching: .any)
.matching(NSPredicate(format: "label == %@", label))
.firstMatch
}
/// The first element anywhere in the app whose label *contains* `fragment`.
///
/// Used only where the whole label is not the test's to predict a welcome row combines a board
/// name, a location and a caption into one element, and the caption is the part under test.
@MainActor
func element(labelContaining fragment: String) -> XCUIElement {
descendants(matching: .any)
.matching(NSPredicate(format: "label CONTAINS %@", fragment))
.firstMatch
}
/// Launches the app on `board` the audit fixture unless told otherwise and waits for its
/// window.
///
/// **Not for `.malformed`**, which has no window to wait for: that variant is launched with
/// `launched(with:)` and the caller waits for welcome instead.
@MainActor
static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
let app = launched(with: board)
// Waiting on *this* window rather than on `windows.firstMatch` is what makes a failed fixture
// build a failure here instead of an audit that quietly passed over the welcome window.
XCTAssertTrue(
app.windows[board.windowTitle].waitForExistence(timeout: uiTimeout),
"the \(board.windowTitle) window did not open — check the app's launch log for a fixture build failure"
)
return app
}
/// Launches the app on `board` and returns as soon as it is frontmost no window assertion, so
/// the caller decides what should have appeared.
@MainActor
static func launched(with board: FixtureBoard) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += board.launchArguments
app.launch()
XCTAssertTrue(
app.wait(for: .runningForeground, timeout: uiTimeout),
"the app did not reach the foreground"
)
return app
}
/// Clicks a menu-bar row by its title.
///
/// **By title, because the titles are API** (04-interactions.md Configurable bindings: menu
/// items are remapped by title through `NSUserKeyEquivalents`, so the strings in
/// `KanbanApp.menuCommands` are already a published contract). Driving the menus rather than
/// typing the chords also means these tests exercise the same path a keyboard user takes, and a
/// row that silently disabled itself fails here as an un-hittable element rather than as a
/// keystroke that went nowhere.
@MainActor
func clickMenuItem(_ title: String, in menu: String) {
let bar = menuBars.firstMatch
let menuBarItem = bar.menuBarItems[menu]
XCTAssertTrue(
menuBarItem.waitForExistence(timeout: Self.uiTimeout),
"the \(menu) menu is missing from the menu bar"
)
menuBarItem.click()
let item = bar.menuItems[title]
XCTAssertTrue(
item.waitForExistence(timeout: Self.uiTimeout),
"\(menu)\(title) is missing"
)
XCTAssertTrue(item.isEnabled, "\(menu)\(title) is disabled")
item.click()
}
/// Whether a menu row is currently enabled opened, read, and closed again with Escape.
///
/// Its own helper because *asking* is a different act from *clicking*: the template-chooser flow
/// needs to know that Choose is live without pressing it, since pressing it raises a save panel
/// this suite cannot drive.
@MainActor
func menuItemIsEnabled(_ title: String, in menu: String) -> Bool {
let bar = menuBars.firstMatch
let menuBarItem = bar.menuBarItems[menu]
guard menuBarItem.waitForExistence(timeout: Self.uiTimeout) else { return false }
menuBarItem.click()
let item = bar.menuItems[title]
let enabled = item.waitForExistence(timeout: Self.uiTimeout) && item.isEnabled
typeKey(.escape, modifierFlags: [])
return enabled
}
/// Selects the fixture's rich card and opens its window.
///
/// **The selection is seeded with one arrow press**, which is the app's own documented rule:
/// "an empty selection seeds at the first lane's first card" (04-interactions.md Grammar,
/// `BoardView.seed`). From there the walk to the rich card is ordinary arrow navigation the
/// same keys a user without a pointer would press so this helper needs to know no identifiers
/// and no geometry, only the fixture's shape.
///
/// The rich card is the sole card of lane 2 ("Doing"), so: one press to seed into lane 1's first
/// card, then one `` to step into lane 2. `` steps to the nearest card in that direction, and
/// with one card in the lane there is only one it can be.
@MainActor
func openRichCardWindow() throws {
typeKey(.downArrow, modifierFlags: [])
typeKey(.rightArrow, modifierFlags: [])
clickMenuItem("Open Card", in: "Board")
XCTAssertTrue(
windows[StandardBoard.richCard].waitForExistence(timeout: Self.uiTimeout),
"the card window did not open"
)
}
// MARK: - Board assertions
/// Waits for a lane to be rendering exactly `count` cards, by its label.
///
/// **This is how every flow in the end-to-end suite asserts that a move landed.** A card's own
/// element carries only its title, which does not change when it changes lanes; the lane's does,
/// because the label carries the count. So "the card moved from To Do to Doing" is checkable as
/// two lane labels and needs no identifier, no geometry and no reading of the disk.
@MainActor
@discardableResult
func awaitLane(_ title: String, cards count: Int, _ message: String = "") -> Bool {
let label = Phrase.lane(title, cards: count)
let found = element(labeled: label).waitForExistence(timeout: Self.uiTimeout)
XCTAssertTrue(found, "expected a lane reading \"\(label)\"\(message.isEmpty ? "" : "\(message)")")
return found
}
/// Waits for the trash column to be rendering exactly `count` cards.
///
/// The trash's *label* is the bare word (it never states a count 03-board-ui.md § Trash keeps
/// the header's title stable), so the count lives in its value and this matches on the pair.
@MainActor
@discardableResult
func awaitTrash(cards count: Int) -> Bool {
let predicate = NSPredicate(
format: "label == %@ AND value == %@", Phrase.trash, Phrase.cards(count)
)
let element = descendants(matching: .any).matching(predicate).firstMatch
let found = element.waitForExistence(timeout: Self.uiTimeout)
XCTAssertTrue(found, "expected the trash column to hold \(Phrase.cards(count))")
return found
}
/// Clicks a card by its title the ordinary single click, which selects it
/// (`BoardStore.click`).
@MainActor
func selectCard(_ title: String) {
let card = element(labeled: title)
XCTAssertTrue(card.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
card.click()
}
/// Waits for an inline title editor and answers it the shared body of every flow that types a
/// title (`InlineTitleField`).
///
/// The editor is born focused, so the typing goes to the app rather than to a located element;
/// what is located is only the *field*, and only so that a flow which never opened one fails here
/// rather than typing its title into the board's keyboard grammar.
///
/// **Matched by placeholder first, then by "the only text field on screen".** The board window's
/// search field is an `NSSearchField` and so is not a `textField` at all, which leaves the inline
/// editor as the sole candidate; the placeholder match is the precise one and the fallback is
/// what keeps a SwiftUI change to how prompts reach the accessibility tree from failing every
/// flow at once.
@MainActor
func typeIntoInlineEditor(_ text: String, prompt: String, replacingExisting: Bool = false) {
let byPlaceholder = textFields
.matching(NSPredicate(format: "placeholderValue == %@", prompt))
.firstMatch
if !byPlaceholder.waitForExistence(timeout: 5) {
XCTAssertTrue(
textFields.firstMatch.waitForExistence(timeout: Self.uiTimeout),
"the inline title editor (\(prompt)) did not appear"
)
}
if replacingExisting {
// The rename editor opens seeded with the current title; Select All inside a focused
// field is the field editor's, not the board's (the board's own Select All disables
// while an inline editor is open 04-interactions.md Grammar).
typeKey("a", modifierFlags: .command)
}
typeText(text)
typeKey(.return, modifierFlags: [])
}
/// Drags one card onto another the pointer path (04-interactions.md Drag and drop), which is
/// a **real AppKit dragging session**: `CardFaceView` starts it from `.onDrag` with an
/// `NSItemProvider`, and the lane strip answers with `onDrop` delegates.
///
/// **This is the most environment-sensitive gesture in the suite**, and deliberately the slowest.
/// The press has to outlast the click-versus-drag threshold before the session arms, the movement
/// has to be slow enough that the drop delegates see intermediate positions (the reflow is
/// computed from them), and the hold at the end has to outlast the drop animation. A failure here
/// is worth re-running once before it is believed.
@MainActor
func dragCard(_ title: String, onto destinationTitle: String) {
let source = element(labeled: title)
let destination = element(labeled: destinationTitle)
XCTAssertTrue(source.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
XCTAssertTrue(
destination.waitForExistence(timeout: Self.uiTimeout),
"the drop target \"\(destinationTitle)\" is not on the board"
)
let centre = CGVector(dx: 0.5, dy: 0.5)
source.coordinate(withNormalizedOffset: centre).press(
forDuration: 1,
thenDragTo: destination.coordinate(withNormalizedOffset: centre),
withVelocity: .slow,
thenHoldForDuration: 1
)
}
}