Files
lanework/KanbanUITests/UITestSupport.swift
T
rzen 9588f7b1f0 Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 21:30:22 -04:00

349 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
/// The comments pane's container label — "Comments, N" (10-accessibility.md ▸ Comments).
static func comments(_ count: Int) -> String {
"Comments, \(count)"
}
}
// 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
)
}
}