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:
@@ -100,20 +100,27 @@ struct RestoreBootstrapView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audit suite's board: built here, opened through the same `openBoard` every other path
|
||||
/// uses, so it registers, bookmarks and titles itself exactly like a board the user opened.
|
||||
/// The UI suites' board: built here, opened through the same `openBoard` every other path uses,
|
||||
/// so it registers, bookmarks and titles itself exactly like a board the user opened.
|
||||
///
|
||||
/// **A failure lands on welcome as an ordinary launch failure**, with the fixture's own path on
|
||||
/// it. That is deliberate: a suite whose fixture failed to build would otherwise audit an empty
|
||||
/// screen and pass, which is the one outcome an accessibility gate must never produce.
|
||||
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
|
||||
/// not care: the malformed variant is built and opened exactly like the other two, and its
|
||||
/// failure arrives one layer down as the *loader's* — a board window that records fail-fast's own
|
||||
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace
|
||||
/// the sentence under test with a sentence about the fixture.
|
||||
///
|
||||
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
|
||||
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
|
||||
/// empty screen and pass, which is the one outcome an accessibility gate must never produce.
|
||||
private func openFixtureBoard() {
|
||||
let variant = UITestLaunch.variant
|
||||
do {
|
||||
let url = try UITestLaunch.materializeFixtureBoard()
|
||||
let url = try UITestLaunch.materializeFixtureBoard(variant)
|
||||
appModel.openBoard(at: url)
|
||||
} catch {
|
||||
Self.logger.error("the UI-test fixture board could not be built: \(error.localizedDescription, privacy: .public)")
|
||||
appModel.recordLaunchFailure(
|
||||
path: UITestLaunch.fixtureBoardURL.path,
|
||||
path: UITestLaunch.fixtureBoardURL(for: variant).path,
|
||||
message: "The UI-test fixture board could not be built: \(error.localizedDescription)"
|
||||
)
|
||||
appModel.showWelcome()
|
||||
|
||||
+244
-16
@@ -52,10 +52,12 @@ enum LaunchPlan: Equatable, Sendable {
|
||||
|
||||
// MARK: - UITestLaunch
|
||||
|
||||
/// **The accessibility audit suite's board**, and the launch argument that asks for it
|
||||
/// (10-accessibility.md ▸ Verification: "Xcode's accessibility audit … runs in UI tests over every
|
||||
/// surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
|
||||
/// template chooser, board popover").
|
||||
/// **The UI suites' boards**, and the launch arguments that ask for them — the accessibility audit's
|
||||
/// fixture (10-accessibility.md ▸ Verification: "Xcode's accessibility audit … runs in UI tests over
|
||||
/// every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
|
||||
/// template chooser, board popover") and, since the end-to-end pass, two more shapes that the audit
|
||||
/// never needed: a **large** board for reflow and launch cost, and a **malformed** one whose only job
|
||||
/// is to fail to load.
|
||||
///
|
||||
/// ### Why the app builds the board instead of being handed one
|
||||
///
|
||||
@@ -73,6 +75,12 @@ enum LaunchPlan: Equatable, Sendable {
|
||||
/// fixture is a board the app made, not a board a test file *believes* is well-formed. A format
|
||||
/// change that broke this would break the app first.
|
||||
///
|
||||
/// **The one deliberate exception is the malformed variant**, which builds its board through the very
|
||||
/// same door and then overwrites exactly one card's `index.md` with raw bytes. That write is the
|
||||
/// point of the variant — there is no Writer call that produces an unparseable file, and there should
|
||||
/// not be one — and it happens *last*, so everything around the broken file is still a board the app
|
||||
/// made.
|
||||
///
|
||||
/// ### It is inert without the flag
|
||||
///
|
||||
/// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached
|
||||
@@ -102,20 +110,31 @@ enum UITestLaunch {
|
||||
|
||||
// MARK: - The flag
|
||||
|
||||
/// The launch argument the audit suite passes (`KanbanUITests/AccessibilityAuditTests.swift`).
|
||||
/// The launch argument every UI suite passes (`KanbanUITests/UITestSupport.swift`), on its own or
|
||||
/// beside a variant flag. It means "this launch is synthetic" and nothing narrower — which board
|
||||
/// is `FixtureVariant`'s to say.
|
||||
///
|
||||
/// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`'
|
||||
/// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of
|
||||
/// side effect a test-only switch must not have.
|
||||
static let fixtureFlag = "--ui-test-fixture-board"
|
||||
|
||||
/// Whether `arguments` asks for the fixture board — **pure**, so the rule is pinned by
|
||||
/// Whether `arguments` asks for a fixture board — **pure**, so the rule is pinned by
|
||||
/// `UITestLaunchTests` rather than by launching an app and looking.
|
||||
///
|
||||
/// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a
|
||||
/// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge.
|
||||
/// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge. That is also
|
||||
/// why the variants below wear *sibling* flags rather than a `=value` suffix or a
|
||||
/// `--flag value` pair — a suffix would demand exactly the prefix matching this rules out, and a
|
||||
/// pair is the shape `NSArgumentDomain` swallows.
|
||||
///
|
||||
/// **A variant flag on its own is enough**, which is belt over braces rather than a second
|
||||
/// spelling: every call site passes `fixtureFlag` too (it is what "this launch is synthetic"
|
||||
/// means), but a test bundle that passed only `--ui-test-fixture-large` must not get a launch
|
||||
/// that reopens the developer's real boards into a scratch registry's blind spot.
|
||||
static func isFixtureLaunch(arguments: [String]) -> Bool {
|
||||
arguments.contains(fixtureFlag)
|
||||
|| FixtureVariant.allCases.contains { arguments.contains($0.flag) }
|
||||
}
|
||||
|
||||
/// The running process's answer to the same question.
|
||||
@@ -123,6 +142,60 @@ enum UITestLaunch {
|
||||
isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
// MARK: - The variants
|
||||
|
||||
/// **Which fixture board a launch asks for.** Three shapes, because the three suites that consume
|
||||
/// them are asking three different questions:
|
||||
///
|
||||
/// - `standard` — the audit's board (three lanes, six cards, a rich card, a trashed card). It is
|
||||
/// also the end-to-end suite's board: every golden flow is expressed against a shape small
|
||||
/// enough to state in a sentence, so an assertion about a lane's card count is readable.
|
||||
/// - `large` — many lanes × many cards, for the masonry, the reflow, and the launch-cost
|
||||
/// measurements. Nothing about it is *interesting*; the point is that there is a lot of it.
|
||||
/// - `malformed` — a well-formed board with exactly one unparseable card `index.md`, for the
|
||||
/// fail-fast pass (01-storage-format.md § Malformed input). It is the only variant whose
|
||||
/// *successful* materialization is expected to produce a *failed* load.
|
||||
///
|
||||
/// The raw value is the flag's tail, so the flag and the case can never drift; the flag is
|
||||
/// double-dashed for `fixtureFlag`'s reason and exact-matched for its reason too.
|
||||
enum FixtureVariant: String, CaseIterable, Sendable {
|
||||
case standard
|
||||
case large
|
||||
case malformed
|
||||
|
||||
/// The launch argument naming this variant. Paired with `fixtureFlag` at every call site.
|
||||
var flag: String { "--ui-test-fixture-\(rawValue)" }
|
||||
|
||||
/// The board's title — and, through `fixtureBoardURL(for:)`, its folder name and its window
|
||||
/// title, so a test can wait on `app.windows["Audit Board"]` and its neighbours.
|
||||
///
|
||||
/// Distinct per variant on purpose: a suite that waited on the wrong title would otherwise
|
||||
/// pass against the wrong board, and the malformed variant's whole assertion is that *no*
|
||||
/// window by its name ever appears.
|
||||
var boardTitle: String {
|
||||
switch self {
|
||||
case .standard: UITestLaunch.boardTitle
|
||||
case .large: "Large Board"
|
||||
case .malformed: "Malformed Board"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant `arguments` asks for — `standard` when none is named, which is what the audit
|
||||
/// suite's bare `--ui-test-fixture-board` has always meant.
|
||||
///
|
||||
/// **Declaration order breaks a tie**, so a launch naming two variants is deterministic rather
|
||||
/// than dependent on argument order. Nothing produces that today; stating the rule is cheaper
|
||||
/// than discovering it.
|
||||
static func variant(arguments: [String]) -> FixtureVariant {
|
||||
FixtureVariant.allCases.first { arguments.contains($0.flag) } ?? .standard
|
||||
}
|
||||
|
||||
/// The running process's answer to the same question.
|
||||
static var variant: FixtureVariant {
|
||||
variant(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
// MARK: - The scratch directory
|
||||
|
||||
/// Everything a fixture launch writes, under one removable root inside the app's container.
|
||||
@@ -141,11 +214,20 @@ enum UITestLaunch {
|
||||
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
|
||||
}
|
||||
|
||||
/// The fixture board's own folder. `.kanban`-suffixed because a board the app made through the
|
||||
/// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
|
||||
/// ordinary create path is a document, and the audit should be looking at the shape a user's
|
||||
/// board actually has (01-storage-format.md § Document packaging).
|
||||
///
|
||||
/// One folder per variant, all under the one scratch root: the root is wiped per launch anyway,
|
||||
/// so the separation buys nothing at runtime — it buys a *name*, which is what a suite waits on.
|
||||
static func fixtureBoardURL(for variant: FixtureVariant) -> URL {
|
||||
scratchRoot.appendingPathComponent("\(variant.boardTitle).kanban", isDirectory: true)
|
||||
}
|
||||
|
||||
/// The audit fixture's folder — `fixtureBoardURL(for: .standard)`, kept as a name because that
|
||||
/// board is the one every caller predating the variants meant.
|
||||
static var fixtureBoardURL: URL {
|
||||
scratchRoot.appendingPathComponent("\(boardTitle).kanban", isDirectory: true)
|
||||
fixtureBoardURL(for: .standard)
|
||||
}
|
||||
|
||||
/// Wipes and recreates the scratch root, and answers the registry URL to build the app model
|
||||
@@ -175,10 +257,10 @@ enum UITestLaunch {
|
||||
return registryStorageURL
|
||||
}
|
||||
|
||||
// MARK: - The fixture board's shape
|
||||
// MARK: - The standard board's shape
|
||||
|
||||
/// The board's title — and, through `fixtureBoardURL`, its folder name and its window title, so
|
||||
/// a test can wait on `app.windows["Audit Board"]`.
|
||||
/// The standard board's title — and, through `fixtureBoardURL`, its folder name and its window
|
||||
/// title, so a test can wait on `app.windows["Audit Board"]`.
|
||||
static let boardTitle = "Audit Board"
|
||||
|
||||
/// The lane titles, in board order. Three because the tree the audit walks should have more than
|
||||
@@ -255,18 +337,104 @@ enum UITestLaunch {
|
||||
/// and Reveal in Finder and never Open. An empty column audits its own label and stops there.
|
||||
static let trashedCardIndex = (lane: 0, card: 1)
|
||||
|
||||
// MARK: - The large board's shape
|
||||
|
||||
/// The large board's lanes and cards — **8 × 40**, which is 320 cards.
|
||||
///
|
||||
/// The numbers are a budget, not a maximum. They are large enough that the two things the large
|
||||
/// board exists to exercise actually happen — a lane wide enough to lay out in several interior
|
||||
/// masonry columns has plenty to lay out, and the board's reflow, scrolling and select-all all
|
||||
/// have real work to do — and small enough that materializing it (a folder and an `index.md` per
|
||||
/// card, through the ordinary Writer) stays in the seconds a UI test can afford. Raising them is
|
||||
/// a decision about how long every performance run takes; they are stated here so that decision
|
||||
/// is made in one place.
|
||||
static let largeLaneCount = 8
|
||||
static let largeCardsPerLane = 40
|
||||
|
||||
static func largeLaneTitle(_ index: Int) -> String {
|
||||
"Lane \(index + 1)"
|
||||
}
|
||||
|
||||
/// A large-board card's title — unique across the whole board, and **deliberately of four
|
||||
/// different lengths**.
|
||||
///
|
||||
/// A wall of identical one-line cards would lay out as a perfect grid, which is exactly the case
|
||||
/// masonry has nothing to do. Cycling the length gives the layout genuinely different card
|
||||
/// heights to balance, so a reflow measured against this board is measuring the work the real
|
||||
/// algorithm does.
|
||||
static func largeCardTitle(lane laneIndex: Int, card cardIndex: Int) -> String {
|
||||
let base = "Card \(laneIndex + 1)-\(cardIndex + 1)"
|
||||
let tail = String(repeating: " with a longer title that wraps", count: cardIndex % 4)
|
||||
return base + tail
|
||||
}
|
||||
|
||||
// MARK: - The malformed board's shape
|
||||
|
||||
/// The malformed board's lanes and cards — small, because nothing about this variant is about
|
||||
/// size. Two lanes so the tree has a shape at all, and two cards in the first so the broken one
|
||||
/// has an intact sibling the loader walked past on its way to it.
|
||||
static let malformedLaneTitles = ["Intact", "Also intact"]
|
||||
|
||||
static let malformedCardTitles: [[String]] = [
|
||||
["A good card", "The malformed card"],
|
||||
["Another good card"],
|
||||
]
|
||||
|
||||
/// Which card gets the raw overwrite, named by `(lane, card)` index.
|
||||
static let malformedCardIndex = (lane: 0, card: 1)
|
||||
|
||||
/// A string appearing only in the malformed file, so a test can *find* that file on disk without
|
||||
/// knowing the UUID the Writer minted for it — and then assert its bytes are exactly what were
|
||||
/// written, which is the "nothing was silently repaired" half of fail-fast.
|
||||
static let malformationMarker = "lanework-ui-test-malformed-fixture"
|
||||
|
||||
/// The bytes written over the doomed card's `index.md`.
|
||||
///
|
||||
/// **The frontmatter opens a flow sequence and never closes it**, so the YAML between the
|
||||
/// delimiters cannot parse — `BoardLoader` rejects the whole board with `.unparseableYAML` naming
|
||||
/// this file, which is the failure the fail-fast pass is about. Everything else in the file is
|
||||
/// well-formed on purpose: the delimiters are there, the body is ordinary text, and the two
|
||||
/// required fields are present in spirit. The one thing wrong with it is the one thing under
|
||||
/// test.
|
||||
static let malformedIndexText = """
|
||||
---
|
||||
schema: 1
|
||||
title: The malformed card
|
||||
order: [1024
|
||||
---
|
||||
|
||||
\(malformationMarker): this card's frontmatter opens a flow sequence and never closes it.
|
||||
The loader must reject the whole board rather than repair, skip, or rewrite anything — and
|
||||
these bytes must still be here, unchanged, after the app has refused to open the board.
|
||||
"""
|
||||
|
||||
// MARK: - Materialization
|
||||
|
||||
/// Builds the fixture board and answers its URL — every write through `BoardWriter`, in the order
|
||||
/// a user would have produced them.
|
||||
/// Builds the fixture board `variant` asks for and answers its URL — every write through
|
||||
/// `BoardWriter`, in the order a user would have produced them, with the malformed variant's one
|
||||
/// raw overwrite as the documented exception (see this type's note).
|
||||
///
|
||||
/// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work,
|
||||
/// and the launch path already has a place for filesystem work that has to happen before the
|
||||
/// first real window (that view's whole reason for existing). A throw surfaces as a launch
|
||||
/// failure on welcome — the same treatment a board that fails to restore gets — so a broken
|
||||
/// fixture is visible rather than a suite that quietly audits an empty screen.
|
||||
static func materializeFixtureBoard() throws -> URL {
|
||||
let root = fixtureBoardURL
|
||||
///
|
||||
/// **The malformed variant does not throw here.** Building it succeeds; *loading* it is what
|
||||
/// fails, one layer up, through the ordinary board-window path — which is the whole point, since
|
||||
/// a fixture that failed to build would surface a message about the fixture rather than the
|
||||
/// loader's own sentence about the offending file.
|
||||
static func materializeFixtureBoard(_ variant: FixtureVariant = .standard) throws -> URL {
|
||||
switch variant {
|
||||
case .standard: try materializeStandardBoard()
|
||||
case .large: try materializeLargeBoard()
|
||||
case .malformed: try materializeMalformedBoard()
|
||||
}
|
||||
}
|
||||
|
||||
/// The audit suite's board (see `boardTitle` and the constants above it).
|
||||
private static func materializeStandardBoard() throws -> URL {
|
||||
let root = fixtureBoardURL(for: .standard)
|
||||
try BoardWriter.createBoard(at: root, title: boardTitle)
|
||||
|
||||
var laneURLs: [URL] = []
|
||||
@@ -302,6 +470,66 @@ enum UITestLaunch {
|
||||
return root
|
||||
}
|
||||
|
||||
/// The performance suite's board — `largeLaneCount` lanes of `largeCardsPerLane` cards, built
|
||||
/// through the same three Writer calls the standard board uses and nothing else.
|
||||
///
|
||||
/// No rich body, no attachment, no trashed card: every one of those is a *feature* the audit
|
||||
/// wanted a specimen of, and this board is not about features. What it is about is quantity, and
|
||||
/// quantity is the only thing that differs.
|
||||
private static func materializeLargeBoard() throws -> URL {
|
||||
let root = fixtureBoardURL(for: .large)
|
||||
try BoardWriter.createBoard(at: root, title: FixtureVariant.large.boardTitle)
|
||||
|
||||
for laneIndex in 0 ..< largeLaneCount {
|
||||
let laneID = try BoardWriter.createLane(inBoard: root, title: largeLaneTitle(laneIndex))
|
||||
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
for cardIndex in 0 ..< largeCardsPerLane {
|
||||
_ = try BoardWriter.createCard(
|
||||
inLane: laneURL,
|
||||
title: largeCardTitle(lane: laneIndex, card: cardIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/// The fail-fast suite's board: an ordinary small board, with one card's `index.md` overwritten
|
||||
/// by `malformedIndexText` **after** every Writer call has finished.
|
||||
///
|
||||
/// The order is the whole design. Building first means the board around the broken file is one
|
||||
/// the app made — right frontmatter, right ranks, right stamps — so the load that follows fails
|
||||
/// for exactly one reason and the loader's sentence names exactly one file. Overwriting first, or
|
||||
/// hand-writing the tree, would have produced a board whose *many* problems the loader would
|
||||
/// report whichever it reached first.
|
||||
///
|
||||
/// The write is `Data.write`, not `BoardWriter.atomicReplace`: the Writer refuses to produce a
|
||||
/// file its own loader would reject, which is a guarantee worth keeping rather than a door worth
|
||||
/// opening. This is the one place in the app that goes around it, and it is unreachable without
|
||||
/// the launch flag.
|
||||
private static func materializeMalformedBoard() throws -> URL {
|
||||
let root = fixtureBoardURL(for: .malformed)
|
||||
try BoardWriter.createBoard(at: root, title: FixtureVariant.malformed.boardTitle)
|
||||
|
||||
var cardURLs: [[URL]] = []
|
||||
for (laneIndex, laneTitle) in malformedLaneTitles.enumerated() {
|
||||
let laneID = try BoardWriter.createLane(inBoard: root, title: laneTitle)
|
||||
let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
var lane: [URL] = []
|
||||
for title in malformedCardTitles[laneIndex] {
|
||||
let cardID = try BoardWriter.createCard(inLane: laneURL, title: title)
|
||||
lane.append(laneURL.appendingPathComponent(cardID.rawValue, isDirectory: true))
|
||||
}
|
||||
cardURLs.append(lane)
|
||||
}
|
||||
|
||||
let doomed = cardURLs[malformedCardIndex.lane][malformedCardIndex.card]
|
||||
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
|
||||
try Data(malformedIndexText.utf8).write(to: doomed, options: .atomic)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
|
||||
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
|
||||
/// folder rather than a hand-placed file the loader would have to normalize.
|
||||
|
||||
Reference in New Issue
Block a user