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
+3 -98
View File
@@ -35,7 +35,9 @@ import XCTest
/// Every test launches with `UITestLaunch.fixtureFlag`, which makes the app build a known board
/// inside its own container and open it (see `UITestLaunch` for why the board cannot simply be
/// handed over on the command line the sandbox). The board is three lanes, six cards, one card
/// with a rich Markdown body and an attachment, and one card already in `.trash/`.
/// with a rich Markdown body and an attachment, and one card already in `.trash/`. That is the
/// `standard` fixture variant; the launch helpers and the other two shapes live in
/// `UITestSupport.swift`.
///
/// ### Running these
///
@@ -165,100 +167,3 @@ final class AccessibilityAuditTests: XCTestCase {
try app.performAccessibilityAudit()
}
}
// 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
}
/// Launches the app on the audit fixture board and waits for its window.
///
/// The flag's spelling is `UITestLaunch.fixtureFlag` in the app target; it is written out here
/// 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.
@MainActor
static func launchedWithFixtureBoard() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += ["--ui-test-fixture-board"]
app.launch()
XCTAssertTrue(
app.wait(for: .runningForeground, timeout: uiTimeout),
"the app did not reach the foreground"
)
// `UITestLaunch.boardTitle`, which is also the window title (`BoardWindowHost.windowTitle`).
// 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["Audit Board"].waitForExistence(timeout: uiTimeout),
"the fixture board window did not open — check the app's launch log for a fixture build failure"
)
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()
}
/// 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")
// `UITestLaunch.cardTitles[1][0]`, which is the card window's title
// (`CardWindowHost.windowTitle`).
XCTAssertTrue(
windows["Write the smoke script"].waitForExistence(timeout: Self.uiTimeout),
"the card window did not open"
)
}
}