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
+164
View File
@@ -0,0 +1,164 @@
import XCTest
/// **A board that will not load, from the outside** (01-storage-format.md § Malformed input;
/// 02-architecture.md § Launch and window lifecycle).
///
/// ### The claim
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) never a silent drop.
///
/// The `malformed` fixture is a well-formed board with exactly one unparseable card `index.md`
/// (`UITestLaunch.malformedIndexText` a frontmatter flow sequence that is never closed). Building
/// it succeeds; loading it must not, and *how* it fails is the whole of this file:
///
/// 1. **No board window.** Not an empty one, not one with the good lanes in it fail-fast is
/// all-or-nothing, so a partial board on screen would be the worse failure.
/// 2. **Welcome, loudly.** The recents row for that board wears the loader's own sentence, which
/// names the offending file. A row that fell back to "Unavailable", or to a count, would be the
/// app declining to say what it found.
/// 3. **Nothing repaired.** The bytes on disk are the bytes the fixture wrote. The loader is a pure
/// function of the tree and writes nothing, ever the Repair precedent so a board it refused
/// must still be refusable, byte for byte.
///
/// ### Where each claim is checked
///
/// The first two are here, because they are about *windows* and a window is what a unit test does not
/// have. The third is checked **both** here and in `KanbanTests` unconditionally there
/// (`UITestMalformedFixtureBoardTests`, which builds the fixture and re-reads the tree), and
/// opportunistically here, because reaching the app's container from the runner depends on how the
/// app under test was signed and installed. Where the container is not reachable this file says so
/// and leans on the unit suite rather than inventing a pass.
final class FailFastLaunchTests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
/// The whole of claims 1 and 2, in one launch: no board window, welcome instead, and the row
/// carrying the loader's specifics.
@MainActor
func testMalformedBoardFailsLoudlyAndOpensNoWindow() throws {
let app = XCUIApplication.launched(with: .malformed)
// Welcome is where a failed open lands (`BoardWindowHost.start`: record the failure, refresh
// the recents, open welcome, dismiss the board window).
XCTAssertTrue(
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the welcome window did not appear after a failed open"
)
// Claim 1. Checked *after* welcome has appeared, so this is "the board window never came",
// not "the board window has not come yet".
XCTAssertFalse(
app.windows[FixtureBoard.malformed.windowTitle].exists,
"a board window opened for a board the loader rejected"
)
// Claim 2. The row is one combined accessibility element name, location, caption and the
// caption is `BoardLoadError.description`: "lane/card/index.md: unparseable YAML at line
// N: ". The UUIDs in that path are minted at launch and unknowable here, so the assertion is
// on the parts that are the *app's* to keep stable: the offending file is named, and the
// reason is stated.
XCTAssertTrue(
app.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout),
"no welcome row named the offending index.md — fail-fast's specifics did not reach the surface"
)
XCTAssertTrue(
app.element(labelContaining: "unparseable YAML").exists,
"the welcome row did not say why the board was refused"
)
// And it is the malformed board's own row that says it.
XCTAssertTrue(
app.element(labelContaining: FixtureBoard.malformed.windowTitle).exists,
"the failure did not land on the failed board's row"
)
}
/// Claim 3, twice over: the malformed bytes survive the refusal, and a second launch is refused
/// the same way rather than opening a board the app quietly fixed.
///
/// The relaunch is not redundant with the byte check it is what the byte check *means* from the
/// user's side, and it is the half that holds even where the container cannot be read.
@MainActor
func testMalformedBoardIsNeverRepaired() throws {
let app = XCUIApplication.launched(with: .malformed)
XCTAssertTrue(
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the welcome window did not appear after a failed open"
)
// The bytes, where the runner can reach them. `NSTemporaryDirectory()` inside the sandboxed
// app resolves to its container, which an unsandboxed test runner can read but only when
// the app under test is installed where this path expects, so a miss is reported rather than
// failed. `UITestMalformedFixtureBoardTests` makes the same claim unconditionally.
if let malformed = Self.malformedIndexOnDisk() {
XCTAssertTrue(
malformed.contains(Self.malformationMarker),
"the malformed index.md no longer carries its marker — something rewrote a file the loader refused to read"
)
XCTAssertTrue(
malformed.contains("order: [1024"),
"the malformed frontmatter was repaired — fail-fast must not write"
)
} else {
// Not a failure, and not silence either: the run says which half of the claim it made.
XCTContext.runActivity(named: "container not reachable from the runner") { _ in
print("""
The app's fixture scratch directory could not be read from the test runner, so \
the on-disk half of "nothing was repaired" was not checked here. It is pinned \
unconditionally by KanbanTests ▸ UITestMalformedFixtureBoardTests.
""")
}
}
// The relaunch. A fresh launch rebuilds the fixture from scratch (the scratch root is wiped
// per launch), so what this proves is the durable half: the app has no repair path that
// would make the second attempt succeed where the first failed.
app.terminate()
let second = XCUIApplication.launched(with: .malformed)
XCTAssertTrue(
second.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the second launch did not reach welcome"
)
XCTAssertFalse(
second.windows[FixtureBoard.malformed.windowTitle].exists,
"the second launch opened the board the first one refused"
)
XCTAssertTrue(
second.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout),
"the second launch did not name the offending file"
)
}
// MARK: - Reading the app's container
/// The string that appears only in the malformed file `UITestLaunch.malformationMarker`,
/// mirrored here for `FixtureBoard`'s reason.
private static let malformationMarker = "lanework-ui-test-malformed-fixture"
/// The malformed card's `index.md`, read from the app's sandbox container or `nil` when the
/// runner cannot reach it.
///
/// The card folder's name is a UUID minted at launch, so the file is found by its content rather
/// than by its path: exactly one `index.md` under the fixture board carries the marker, which is
/// what the marker is for.
@MainActor
private static func malformedIndexOnDisk() -> String? {
let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
let board = home
.appendingPathComponent("Library/Containers/dev.rzen.indie.Kanban/Data/tmp", isDirectory: true)
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
.appendingPathComponent("\(FixtureBoard.malformed.windowTitle).kanban", isDirectory: true)
guard let walker = FileManager.default.enumerator(atPath: board.path) else { return nil }
for case let relative as String in walker where relative.hasSuffix("index.md") {
guard let data = try? Data(contentsOf: board.appendingPathComponent(relative)) else { continue }
let text = String(decoding: data, as: UTF8.self)
if text.contains(malformationMarker) { return text }
}
return nil
}
}