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
+312
View File
@@ -0,0 +1,312 @@
import XCTest
/// **The golden paths, driven through the shipping app** create, rename, move, cut and paste,
/// delete, restore, empty the trash, undo and redo.
///
/// ### What this suite is for, and what it is not
///
/// Every rule these flows exercise is already pinned somewhere in `KanbanTests`: the write paths by
/// `*WriteTests`, the grammar by `SelectionGrammarTests` and `KeyboardGrammarTests`, the trash by
/// `TrashModelTests` and `TrashWriteTests`, the stack by `UndoWriteTests`. Not one assertion below is
/// a fact those suites cannot state faster and more precisely.
///
/// What they cannot state is that the pieces are *wired together* that File New Card reaches
/// `beginNewCard`, that the editor it opens is focused, that Return commits it, that the write lands,
/// that the watcher rounds it back, and that the lane the user is looking at now says "4 cards". This
/// suite is that sentence, once per flow, and nothing else. When one of these fails and the unit
/// suites stay green, the defect is in the wiring a menu item scoped to the wrong focus value, a
/// command that lost its shortcut, a reload that never arrived.
///
/// ### How a flow asserts
///
/// **On lane labels**, almost always (`XCUIApplication.awaitLane`). A lane's accessibility label is
/// "title, lane, N cards" and the count is the *rendered* one, so one label assertion covers the
/// visible badge, the spoken count and the layout at once 10-accessibility.md Trash lane makes
/// them one number, and this is where that pays. A card's own element carries only its title, which
/// does not change when the card changes lanes, so asserting on the card would assert nothing about
/// where it went.
///
/// The fixture is the `standard` board: three lanes **To Do** (3 cards), **Doing** (1), **Done**
/// (1) plus one card already in `.trash/`. Every flow below starts from exactly that and states
/// its own arithmetic against it.
///
/// ### Running these
///
/// They drive the real app through the real menu bar, so the machine running them must have granted
/// the test runner Accessibility control (System Settings Privacy & Security Accessibility) and
/// must not be locked or headless the whole of `KanbanUITests/EndToEndVerification.md`'s
/// prerequisites section, which is where the run command lives too.
///
/// ### Reading a failure
///
/// A failure on a `clickMenuItem` is a *validation* failure: the row was missing or disabled, which
/// means a focused value did not reach it. A failure on an `awaitLane` is the flow itself: the
/// command ran and the board did not end up where the design says it should. A failure on a
/// `waitForExistence` for a card or an editor is navigation, in this file.
final class EndToEndFlowTests: XCTestCase {
override func setUp() {
super.setUp()
// A flow is a sequence: once a step has failed, every later assertion is about a board in a
// state nobody wrote down.
continueAfterFailure = false
}
// MARK: - Creating
/// **File New Card (N)** the placeholder opens focused in the target lane, Return commits,
/// and the card is on the board (04-interactions.md Grammar: "Return commits and re-selects the
/// lane").
///
/// The target is made explicit by selecting a card in "To Do" first. N with nothing selected
/// would also land there (`NewCardTarget.resolve` falls back to the first lane), but a flow that
/// relies on a fallback is a flow that would pass for the wrong reason.
@MainActor
func testCreateCard() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.selectCard(StandardBoard.firstLaneCards[0])
app.clickMenuItem("New Card", in: "File")
app.typeIntoInlineEditor("A card the suite made", prompt: Phrase.cardTitlePrompt)
XCTAssertTrue(
app.element(labeled: "A card the suite made").waitForExistence(timeout: XCUIApplication.uiTimeout),
"the new card never appeared on the board"
)
app.awaitLane(StandardBoard.toDo, cards: 4, "the new card should have landed in To Do")
}
/// **File New Lane (N)** a lane with no title and no editor (`BoardStore.createLane`), so
/// it arrives reading the untitled placeholder with an empty count.
///
/// The count is the assertion that matters: a new lane that arrived holding cards would mean the
/// create had landed somewhere it should not have.
@MainActor
func testCreateLane() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.clickMenuItem("New Lane", in: "File")
app.awaitLane(Phrase.untitled, cards: 0, "File ▸ New Lane should add one empty, untitled lane")
// The three lanes that were already there are untouched a create is not a re-divide of the
// board's contents.
app.awaitLane(StandardBoard.toDo, cards: 3)
app.awaitLane(StandardBoard.doing, cards: 1)
app.awaitLane(StandardBoard.done, cards: 1)
}
// MARK: - Renaming
/// **Board Rename** on a card the inline editor, seeded with the current title, committed
/// with Return (04-interactions.md Selection; the menu row exists "for completeness and
/// remapping", the pointer path being a slow double click).
///
/// Select All then type is how the seeded title is replaced: inside a focused field that chord
/// belongs to the field editor, because the board's own Select All disables while an inline
/// editor is open.
@MainActor
func testRenameCardInline() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
let original = StandardBoard.firstLaneCards[3]
let renamed = "Ship the audit, renamed"
app.selectCard(original)
app.clickMenuItem("Rename", in: "Board")
app.typeIntoInlineEditor(renamed, prompt: Phrase.cardTitlePrompt, replacingExisting: true)
XCTAssertTrue(
app.element(labeled: renamed).waitForExistence(timeout: XCUIApplication.uiTimeout),
"the renamed card never appeared"
)
// A rename is not a move: the lane still holds the same three cards.
app.awaitLane(StandardBoard.toDo, cards: 3)
}
// MARK: - Moving
/// **A pointer drag across lanes** (04-interactions.md Drag and drop) the card leaves "To Do"
/// and lands in "Doing", which the two lanes' counts say and nothing else has to.
///
/// This is the suite's one gesture that goes through a real AppKit dragging session rather than
/// through a menu, and it is correspondingly the most environment-sensitive
/// (`XCUIApplication.dragCard` carries the details). It earns its place anyway: the drag is the
/// board's headline interaction, its reflow is the largest piece of geometry in the app, and no
/// unit test can press a mouse button.
@MainActor
func testDragCardBetweenLanes() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.dragCard(StandardBoard.firstLaneCards[0], onto: StandardBoard.richCard)
app.awaitLane(StandardBoard.doing, cards: 2, "the dragged card should have landed in Doing")
app.awaitLane(StandardBoard.toDo, cards: 2, "the dragged card should have left To Do")
}
/// **Cut and paste across lanes (X / V)** the keyboard's own move (04-interactions.md
/// Clipboard), driven through the Edit menu because the board answers `cut:`/`paste:` as a
/// responder and the menu is where that wiring is visible.
///
/// The paste lands the card **after** the anchor card, which is why the destination is selected
/// by clicking a card in it rather than by clicking the lane.
@MainActor
func testCutAndPasteCardAcrossLanes() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.selectCard(StandardBoard.firstLaneCards[0])
app.clickMenuItem("Cut", in: "Edit")
app.selectCard(StandardBoard.richCard)
app.clickMenuItem("Paste", in: "Edit")
app.awaitLane(StandardBoard.doing, cards: 2, "the cut card should have landed in Doing")
app.awaitLane(StandardBoard.toDo, cards: 2, "the cut card should have left To Do")
}
/// **Z and Z over a move** (13-native-undo.md Rules) the chords rather than the menu rows,
/// because those rows are titled dynamically ("Undo Move Card") by `NSUndoManager` and a test
/// that spelled the title would be asserting the platform's composition rather than the app's
/// stack.
///
/// The move is the cut/paste above, so what undo has to put back is a folder that physically
/// changed lanes the interesting case, and the one the native provider registers an inverse
/// for at the Writer boundary.
@MainActor
func testUndoAndRedoOfAMove() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.selectCard(StandardBoard.firstLaneCards[0])
app.clickMenuItem("Cut", in: "Edit")
app.selectCard(StandardBoard.richCard)
app.clickMenuItem("Paste", in: "Edit")
app.awaitLane(StandardBoard.doing, cards: 2)
app.typeKey("z", modifierFlags: .command)
app.awaitLane(StandardBoard.toDo, cards: 3, "⌘Z should have returned the card to To Do")
app.awaitLane(StandardBoard.doing, cards: 1)
app.typeKey("z", modifierFlags: [.shift, .command])
app.awaitLane(StandardBoard.doing, cards: 2, "⇧⌘Z should have moved the card back into Doing")
app.awaitLane(StandardBoard.toDo, cards: 2)
}
// MARK: - The trash
/// **Delete, then restore** the whole of the current trash grammar's board side
/// (03-board-ui.md § Trash, resettled 2026-07-28 for the materialized trash).
///
/// Four claims in one flow, because they are one story:
///
/// 1. **File Delete on a board card does not confirm.** The card moves into `<root>/.trash/`
/// and that is recoverable, so there is no alert this flow would hang on one if there were.
/// 2. **View Show Trash** puts the column on the board as the last container, holding what was
/// already in it plus what just arrived.
/// 3. **Restore is an ordinary move out**: X in the trash, V into a lane. There is no Put Back
/// and there is nothing else to press.
/// 4. The counts on both sides move, both times.
@MainActor
func testDeleteToTrashAndRestore() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
let doomed = StandardBoard.firstLaneCards[0]
app.selectCard(doomed)
app.clickMenuItem("Delete", in: "File")
app.awaitLane(StandardBoard.toDo, cards: 2, "the deleted card should have left the lane")
app.clickMenuItem("Show Trash", in: "View")
app.awaitTrash(cards: 2)
// The restore: cut in the trash, paste into a live lane.
app.selectCard(doomed)
app.clickMenuItem("Cut", in: "Edit")
app.selectCard(StandardBoard.doneCard)
app.clickMenuItem("Paste", in: "Edit")
app.awaitLane(StandardBoard.done, cards: 2, "the restored card should have landed in Done")
app.awaitTrash(cards: 1)
}
/// **File Empty Trash ()** "the alert always appears" (03-board-ui.md § Trash), naming
/// the true count, and confirming it purges the container.
///
/// The trash has to be *shown* for the row to be enabled at all ("hidden, it is invisible to
/// every gesture"), which is the first click here and is itself part of the claim.
@MainActor
func testEmptyTrash() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.clickMenuItem("Show Trash", in: "View")
app.awaitTrash(cards: 1)
app.clickMenuItem("Empty Trash…", in: "File")
// The prompt is `TrashModel.emptyTrashPrompt`: the count in the title, Delete and Cancel as
// the two answers. It is asserted rather than merely dismissed, because "the alert always
// appears" is the design rule under test.
//
// Queried app-wide rather than through `dialogs`/`sheets`, deliberately: which of those a
// SwiftUI `.alert` surfaces as on macOS is the framework's choice and not a contract, while
// the button titles and the sentence are `TrashConfirmations`' and `TrashModel`'s.
let confirm = app.buttons["Delete"]
XCTAssertTrue(
confirm.waitForExistence(timeout: XCUIApplication.uiTimeout),
"Empty Trash… did not raise its confirmation"
)
XCTAssertTrue(
app.element(labelContaining: "Permanently delete \(Phrase.cards(1))").exists,
"the confirmation did not name the trash's count"
)
XCTAssertTrue(app.buttons["Cancel"].exists, "the confirmation had no Cancel")
confirm.click()
app.awaitTrash(cards: 0)
}
// MARK: - Templates
/// **File New Board (N) as far as this suite can drive it** the chooser opens, the bundled
/// templates are listed and selectable, and Choose is live.
///
/// ### Why it stops there
///
/// Choose runs an `NSSavePanel`, and in a sandboxed app that panel is **Powerbox** a separate,
/// system-owned process (`com.apple.appkit.xpc.openAndSavePanelService`). Driving it means
/// driving another application's UI from inside this one's test, on a surface Apple changes
/// between releases and which has no accessibility contract of its own. A test that did it would
/// fail for reasons that have nothing to do with this app, which is worse than not having the
/// test: a flaky gate teaches people to re-run rather than to read.
///
/// So the automated half ends at the panel, and **instantiating a template is a manual step**
/// written down in `KanbanUITests/EndToEndVerification.md` Manual-only flows rather than faked
/// here. Everything behind the panel is unit-tested (`TemplateEngineTests` covers the copy, the
/// `template:` key, the collision ladder and the cancellation).
@MainActor
func testTemplateChooserUpToTheSavePanel() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
app.clickMenuItem("New Board…", in: "File")
let chooser = app.windows["New Board"]
XCTAssertTrue(
chooser.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the template chooser did not appear"
)
// "Basic" is the bundled tier's lowest `template.order`, so it is the row the chooser opens
// on and a tile is a button to the accessibility tree, labeled by its name
// (10-accessibility.md Template chooser).
let basic = app.element(labeled: "Basic")
XCTAssertTrue(basic.waitForExistence(timeout: XCUIApplication.uiTimeout), "the Basic template is not listed")
basic.click()
let choose = chooser.buttons["Choose"]
XCTAssertTrue(choose.exists, "the chooser has no Choose button")
XCTAssertTrue(choose.isEnabled, "Choose is disabled on a loadable template")
// Cancel rather than Choose: pressing Choose hands the flow to Powerbox (see above). The
// chooser closing is the last thing this test can honestly assert.
chooser.buttons["Cancel"].click()
XCTAssertTrue(
chooser.waitForNonExistence(timeout: XCUIApplication.uiTimeout),
"Cancel did not dismiss the template chooser"
)
}
}