macOS keeps Saved Application State per app, and on macOS 26 its mere existence — even describing zero windows, which repeated dev kills guarantee — counts as "a restored session": SwiftUI then treats every scene's defaultLaunchBehavior as moot and presents nothing. The app launched as a windowless shell with no way back, since windowOpener is captured by the first scene that appears — so Open Recent, the re-grant Grant click, and Dock reopen all silently buffered or no-opped. Proven by -ApplePersistenceIgnoreState YES presenting correctly on the same state; with the fix, welcome presented 3/3 consecutive plain launches. Three changes: - App.init registers ApplePersistenceIgnoreState — restoration is the registry's job (02 § Launch and window lifecycle), every scene already declares restorationBehavior(.disabled), and AppKit's layer was pure liability. Registered before NSApplicationMain runs, which is what makes a registration-domain default early enough. - The restore bootstrap presents at every launch as the app's one reliable presenter; welcome is never system-presented (.suppressed) — the pass opens it when nothing else lands on screen. LaunchPlan.presentsBootstrap retired. - captureWindowActions returns the replayed Finder-open count so the pass counts those as opens — a cold document launch doesn't get welcome stacked beside its board. Both suites green, verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
381 lines
20 KiB
Swift
381 lines
20 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The launch decision and the audit suite's fixture board (10-accessibility.md ▸ Verification).
|
|
///
|
|
/// Two halves, tested for two different reasons. `LaunchPlan.decide` and the flag predicate are
|
|
/// **pure**, so they are pinned here the way every other launch-time rule in this app is — without a
|
|
/// `UserDefaults` domain, a live registry, or a running app (`AppModel.shouldRestoreAtLaunch`'s own
|
|
/// argument, which this composes).
|
|
///
|
|
/// The fixture board is tested here for a blunter reason: **the UI suite that consumes it cannot be
|
|
/// run in every environment** (it needs Accessibility automation permission and an unlocked
|
|
/// display), and a fixture that quietly failed to build would turn every audit into a pass over an
|
|
/// empty screen. Loading it back through the ordinary `BoardLoader` is the one check that runs
|
|
/// everywhere and would catch that.
|
|
|
|
// MARK: - The launch plan
|
|
|
|
@Suite("The launch plan")
|
|
struct LaunchPlanTests {
|
|
|
|
/// The three-way decision, exhaustively — the two-way gate `AppModel.shouldRestoreAtLaunch`
|
|
/// already owns, plus the fixture's outright precedence over both halves of it.
|
|
///
|
|
/// The precedence matters more than it looks: a UI-test launch that also restored the
|
|
/// developer's flagged boards would open real documents, run real watchers over them, and stamp
|
|
/// real registry records — during a test run whose whole premise is that nothing outside the
|
|
/// scratch directory is touched.
|
|
@Test(
|
|
"The fixture wins outright; otherwise the restore gate decides",
|
|
arguments: [
|
|
(fixture: true, preference: true, restorables: true, expected: LaunchPlan.uiTestFixture),
|
|
(fixture: true, preference: false, restorables: false, expected: LaunchPlan.uiTestFixture),
|
|
(fixture: false, preference: true, restorables: true, expected: LaunchPlan.restoreBoards),
|
|
(fixture: false, preference: true, restorables: false, expected: LaunchPlan.welcome),
|
|
(fixture: false, preference: false, restorables: true, expected: LaunchPlan.welcome),
|
|
(fixture: false, preference: false, restorables: false, expected: LaunchPlan.welcome),
|
|
]
|
|
)
|
|
func decision(fixture: Bool, preference: Bool, restorables: Bool, expected: LaunchPlan) {
|
|
#expect(
|
|
LaunchPlan.decide(
|
|
isUITestFixtureLaunch: fixture,
|
|
restorePreference: preference,
|
|
hasRestorables: restorables
|
|
) == expected
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - The flag
|
|
|
|
@Suite("The UI-test fixture flag")
|
|
struct UITestLaunchFlagTests {
|
|
|
|
@Test("An exact occurrence anywhere in the argument list asks for the fixture")
|
|
func flagRecognized() {
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework", UITestLaunch.fixtureFlag]))
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: [UITestLaunch.fixtureFlag, "-NSTreatUnknownArgumentsAsOpen", "NO"]))
|
|
}
|
|
|
|
/// An ordinary launch — including the one XCUITest performs with no arguments of its own — is
|
|
/// never a fixture launch.
|
|
@Test("An absent flag is an ordinary launch")
|
|
func flagAbsent() {
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: []) == false)
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework"]) == false)
|
|
}
|
|
|
|
/// **Exact match, not a prefix.** A launch switch with a fuzzy edge is a launch switch that can
|
|
/// be tripped by accident, and this one redirects the registry — the one place an accident would
|
|
/// look like the user's recents list having been wiped.
|
|
@Test("A near-miss is not the flag")
|
|
func flagNotMatchedLoosely() {
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)s"]) == false)
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)=basic"]) == false)
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-board"]) == false)
|
|
}
|
|
|
|
/// The flag is double-dashed so `UserDefaults`' `NSArgumentDomain` — which reads `-key value`
|
|
/// pairs — never sees it as a preference. Stated as a test because the consequence of getting it
|
|
/// wrong is invisible: the app would work, and a stray defaults key would appear.
|
|
@Test("The flag cannot be read as an argument-domain preference key")
|
|
func flagIsNotAPreferenceKey() {
|
|
#expect(UITestLaunch.fixtureFlag.hasPrefix("--"))
|
|
}
|
|
}
|
|
|
|
// MARK: - The variant flags
|
|
|
|
/// The three fixture shapes and the arguments that name them (`UITestLaunch.FixtureVariant`).
|
|
///
|
|
/// The parsing is the flag's, restated one level down — exact match, double dash, no `=value` and no
|
|
/// `--flag value` pair — so the whole family has one edge rather than two, and the tie-break exists
|
|
/// so a launch naming two variants is a decided case rather than an argument-order accident.
|
|
@Suite("The UI-test fixture variants")
|
|
struct UITestFixtureVariantTests {
|
|
|
|
@Test("A bare fixture flag is the standard board", arguments: [
|
|
["--ui-test-fixture-board"],
|
|
["/path/to/Lanework", "--ui-test-fixture-board", "-NSTreatUnknownArgumentsAsOpen", "NO"],
|
|
[],
|
|
])
|
|
func standardIsTheDefault(arguments: [String]) {
|
|
#expect(UITestLaunch.variant(arguments: arguments) == .standard)
|
|
}
|
|
|
|
@Test("A variant flag names its variant", arguments: UITestLaunch.FixtureVariant.allCases)
|
|
func variantRecognized(variant: UITestLaunch.FixtureVariant) {
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, variant.flag]) == variant)
|
|
// The pairing every call site uses is base-flag-plus-variant, but the variant alone is
|
|
// enough to mark the launch synthetic — otherwise a bundle that forgot the base flag would
|
|
// get an ordinary launch over the developer's real boards.
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: [variant.flag]))
|
|
}
|
|
|
|
/// The same fuzzy-edge rule the base flag has, applied to the family: a near-miss is not a flag,
|
|
/// and a near-miss is therefore not a fixture launch either.
|
|
@Test("A near-miss is not a variant flag")
|
|
func variantNotMatchedLoosely() {
|
|
let large = UITestLaunch.FixtureVariant.large
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "\(large.flag)r"]) == .standard)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-board=large"]) == .standard)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-variant", "large"]) == .standard)
|
|
#expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-large"]) == false)
|
|
}
|
|
|
|
/// Declaration order breaks a tie, whatever order the arguments arrived in.
|
|
@Test("Two variants named at once resolve in declaration order")
|
|
func variantTieBreak() {
|
|
let flags = [UITestLaunch.FixtureVariant.malformed.flag, UITestLaunch.FixtureVariant.large.flag]
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags) == .large)
|
|
#expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags.reversed()) == .large)
|
|
}
|
|
|
|
/// Every variant flag is double-dashed, for the base flag's reason, and every one of them is
|
|
/// distinct from the base flag and from its siblings — the window titles below rest on the same
|
|
/// distinctness, so a duplicate would be two boards claiming one name.
|
|
@Test("The flags and titles are double-dashed and distinct")
|
|
func flagsAreWellFormed() {
|
|
let variants = UITestLaunch.FixtureVariant.allCases
|
|
#expect(variants.allSatisfy { $0.flag.hasPrefix("--") })
|
|
#expect(Set(variants.map(\.flag)).count == variants.count)
|
|
#expect(variants.allSatisfy { $0.flag != UITestLaunch.fixtureFlag })
|
|
#expect(Set(variants.map(\.boardTitle)).count == variants.count)
|
|
// The standard variant's title is the one the audit suite has always waited on.
|
|
#expect(UITestLaunch.FixtureVariant.standard.boardTitle == UITestLaunch.boardTitle)
|
|
#expect(UITestLaunch.fixtureBoardURL == UITestLaunch.fixtureBoardURL(for: .standard))
|
|
}
|
|
}
|
|
|
|
// MARK: - The fixture board
|
|
|
|
@Suite("The audit fixture board")
|
|
struct UITestFixtureBoardTests {
|
|
|
|
/// Builds the fixture exactly as a UI-test launch does, then reads it back through the ordinary
|
|
/// loader — the app's own answer to "is this a board", so the assertion is the same one the
|
|
/// board window would make.
|
|
///
|
|
/// The scratch directory is prepared first (which is what wipes any previous run's board) and
|
|
/// removed afterwards, so this test leaves the container as it found it.
|
|
@Test("It builds, loads, and has the shape the audit suite navigates")
|
|
func fixtureLoads() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
let root = try UITestLaunch.materializeFixtureBoard()
|
|
let model = try BoardLoader.load(boardRoot: root).model
|
|
|
|
// The window title the UI suite waits on.
|
|
#expect(model.title.value == UITestLaunch.boardTitle)
|
|
#expect(root.lastPathComponent == "\(UITestLaunch.boardTitle).kanban")
|
|
|
|
// Three lanes, in the order the fixture names them — which is also the order VoiceOver reads
|
|
// them in (10-accessibility.md ▸ Logical order), so a fixture whose lanes came out shuffled
|
|
// would make the traversal-order check meaningless.
|
|
#expect(model.lanes.map(\.title.value) == UITestLaunch.laneTitles)
|
|
|
|
// The first lane is the crowded one, minus the card the fixture deleted — the masonry
|
|
// divergence the audit is most interested in needs more than one card to diverge.
|
|
#expect(model.lanes[0].cards.count == UITestLaunch.cardTitles[0].count - 1)
|
|
#expect(model.lanes[0].cards.allSatisfy { $0.title.value != UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card] })
|
|
|
|
// The trashed card is in the trash container and nowhere else — cards only, no lane entries
|
|
// (03-board-ui.md § Trash).
|
|
#expect(model.trash.count == 1)
|
|
#expect(model.trash.first?.title.value == UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card])
|
|
|
|
// The rich card: the one the card-window audits open. Its window title is what the UI suite
|
|
// waits on, its body is what Preview renders into the tree, and its attachment is what the
|
|
// card element's value and the sidebar's row are made of.
|
|
let richLane = model.lanes[UITestLaunch.richCardIndex.lane]
|
|
let richCard = try #require(richLane.cards.first)
|
|
#expect(richCard.title.value == UITestLaunch.cardTitles[UITestLaunch.richCardIndex.lane][UITestLaunch.richCardIndex.card])
|
|
#expect(richCard.body.contains("## What this card is for"))
|
|
#expect(richCard.attachments == [UITestLaunch.attachmentName])
|
|
}
|
|
|
|
/// Everything the fixture launch writes stays inside the app's own container — the sandbox
|
|
/// constraint that decided the whole design (a path handed over on the command line would not be
|
|
/// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
|
|
///
|
|
/// Every variant's board, not just the audit's: they share one scratch root by construction, and
|
|
/// this is the assertion that keeps a future variant from inventing a second home.
|
|
@Test("Everything it writes is inside the app container")
|
|
func scratchIsContained() {
|
|
let container = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).standardizedFileURL.path
|
|
#expect(UITestLaunch.scratchRoot.standardizedFileURL.path.hasPrefix(container))
|
|
#expect(UITestLaunch.registryStorageURL.standardizedFileURL.path.hasPrefix(container))
|
|
for variant in UITestLaunch.FixtureVariant.allCases {
|
|
#expect(UITestLaunch.fixtureBoardURL(for: variant).standardizedFileURL.path.hasPrefix(container))
|
|
}
|
|
}
|
|
|
|
/// The fixture registry is **not** the real one — the clause that keeps an audit run out of the
|
|
/// user's recents list.
|
|
@Test("The fixture registry is not the app's real registry")
|
|
@MainActor
|
|
func registryIsRedirected() {
|
|
#expect(UITestLaunch.registryStorageURL != BoardRegistry.defaultStorageURL)
|
|
}
|
|
}
|
|
|
|
// MARK: - The large board
|
|
|
|
/// The performance suite's board (`UITestLaunch.FixtureVariant.large`).
|
|
///
|
|
/// Tested here for the audit fixture's reason turned up a notch: **the suite that consumes it cannot
|
|
/// be run in every environment**, and a large board that quietly came out small would turn a
|
|
/// performance measurement into a measurement of something else — one that *passes*, since a smaller
|
|
/// board is a faster one. So the counts are asserted, through the ordinary loader, where they can be
|
|
/// checked anywhere.
|
|
@Suite("The large fixture board")
|
|
struct UITestLargeFixtureBoardTests {
|
|
|
|
@Test("It builds at the stated size and loads through the ordinary loader")
|
|
func largeBoardLoads() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
let root = try UITestLaunch.materializeFixtureBoard(.large)
|
|
let result = try BoardLoader.load(boardRoot: root)
|
|
let model = result.model
|
|
|
|
// The window title the performance suite waits on.
|
|
#expect(model.title.value == UITestLaunch.FixtureVariant.large.boardTitle)
|
|
#expect(root == UITestLaunch.fixtureBoardURL(for: .large))
|
|
|
|
// The size the budgets in `EndToEndVerification.md` are budgets *for*. A board that came out
|
|
// a different size makes every one of them a number about a different board.
|
|
#expect(model.lanes.count == UITestLaunch.largeLaneCount)
|
|
#expect(model.lanes.allSatisfy { $0.cards.count == UITestLaunch.largeCardsPerLane })
|
|
#expect(model.lanes.map(\.cards.count).reduce(0, +) == UITestLaunch.largeLaneCount * UITestLaunch.largeCardsPerLane)
|
|
|
|
// Lanes in board order, exactly as named — the same claim the audit fixture makes, and for
|
|
// the same reason: a shuffled board would make a lane-addressed assertion meaningless.
|
|
#expect(model.lanes.map(\.title.value) == (0 ..< UITestLaunch.largeLaneCount).map(UITestLaunch.largeLaneTitle))
|
|
|
|
// Cards in card order within each lane, and every title distinct across the whole board —
|
|
// which is what lets a UI test name one card and mean one card.
|
|
for (laneIndex, lane) in model.lanes.enumerated() {
|
|
let expected = (0 ..< UITestLaunch.largeCardsPerLane).map {
|
|
UITestLaunch.largeCardTitle(lane: laneIndex, card: $0)
|
|
}
|
|
#expect(lane.cards.map(\.title.value) == expected)
|
|
}
|
|
let titles = model.lanes.flatMap { $0.cards.compactMap(\.title.value) }
|
|
#expect(Set(titles).count == titles.count)
|
|
|
|
// Four title lengths, cycled — the masonry has different card heights to balance rather than
|
|
// a perfect grid, which is the one thing about this board that is not simply "a lot of it".
|
|
let firstLane = try #require(model.lanes.first)
|
|
#expect(Set(firstLane.cards.compactMap(\.title.value).prefix(4).map(\.count)).count == 4)
|
|
|
|
// Nothing tolerated-but-notable: this board is built through the Writer alone, so a warning
|
|
// here would mean the *builder* left a stray behind.
|
|
#expect(result.warnings.isEmpty)
|
|
#expect(model.trash.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - The malformed board
|
|
|
|
/// The fail-fast suite's board (`UITestLaunch.FixtureVariant.malformed`) — and the two claims the UI
|
|
/// suite makes about it, pinned where they can be checked without a display.
|
|
///
|
|
/// 01-storage-format.md § Malformed input is the rule under test: a structurally broken `index.md`
|
|
/// rejects **the whole load**, loudly, naming the file — and the app never rewrites what it could not
|
|
/// read (the Repair precedent, which `BoardLoader`'s own note states as "a load is a pure function of
|
|
/// the tree and writes nothing, ever").
|
|
@Suite("The malformed fixture board")
|
|
struct UITestMalformedFixtureBoardTests {
|
|
|
|
@Test("It builds, and then fails to load — loudly, naming the offending file")
|
|
func malformedBoardFailsFast() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
// Building succeeds. That is the point: the failure under test is the *loader's*, so a
|
|
// fixture that threw on the way in would surface a different sentence entirely.
|
|
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
|
#expect(root == UITestLaunch.fixtureBoardURL(for: .malformed))
|
|
|
|
do {
|
|
_ = try BoardLoader.load(boardRoot: root)
|
|
Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
|
|
} catch let error as BoardLoadError {
|
|
// The path is board-relative and names the *file*, which is what the welcome row's
|
|
// failure caption carries and what the UI test asserts against.
|
|
#expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
|
|
#expect(error.path.split(separator: "/").count == 3, "the offending path names <lane>/<card>/index.md")
|
|
|
|
// The reason is the one the bytes were written to produce — unparseable YAML, not a
|
|
// missing field. A future edit to `malformedIndexText` that accidentally produced a
|
|
// *valid* file with a missing key would still fail the load, and this line is what
|
|
// would notice.
|
|
if case .unparseableYAML = error.reason {} else {
|
|
Issue.record("expected unparseable YAML, got \(error.reason)")
|
|
}
|
|
|
|
// The whole sentence, which is what actually reaches the user: file first, then why.
|
|
#expect(error.description.contains(BoardLoader.indexFileName))
|
|
#expect(error.description.lowercased().contains("yaml"))
|
|
} catch {
|
|
Issue.record("expected a BoardLoadError, got \(error)")
|
|
}
|
|
}
|
|
|
|
/// **Nothing is silently repaired.** The refused load leaves the malformed bytes exactly as they
|
|
/// were written — no rewrite, no relocation into `.trash/`, no skip-and-continue — and the intact
|
|
/// siblings are untouched too.
|
|
///
|
|
/// This is the claim the UI suite can only make opportunistically (it can read the app's
|
|
/// container when the runner can reach it), so it is made unconditionally here.
|
|
@Test("A refused load repairs nothing")
|
|
func malformedBoardIsNotRepaired() throws {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }
|
|
|
|
let root = try UITestLaunch.materializeFixtureBoard(.malformed)
|
|
let before = try Self.tree(under: root)
|
|
|
|
// Twice, because a repair that only ran on the second attempt would be the worst kind.
|
|
for _ in 0 ..< 2 {
|
|
do {
|
|
_ = try BoardLoader.load(boardRoot: root)
|
|
Issue.record("the malformed board loaded")
|
|
} catch let error as BoardLoadError {
|
|
#expect(error.path.hasSuffix(BoardLoader.indexFileName))
|
|
} catch {
|
|
Issue.record("expected a BoardLoadError, got \(error)")
|
|
}
|
|
}
|
|
|
|
#expect(try Self.tree(under: root) == before)
|
|
|
|
// And the bytes themselves are the ones the fixture wrote, marker included — the string a UI
|
|
// test searches the container for when it can reach it.
|
|
let malformed = try #require(before.first { $0.value.contains(UITestLaunch.malformationMarker) })
|
|
#expect(malformed.value == UITestLaunch.malformedIndexText)
|
|
#expect(malformed.key.hasSuffix("/\(BoardLoader.indexFileName)"))
|
|
}
|
|
|
|
/// Every `index.md` beneath `root`, keyed by its board-relative path, read as raw text. Hidden
|
|
/// entries included, so a relocation into `.trash/` would show up as a new key rather than as a
|
|
/// silence.
|
|
private static func tree(under root: URL) throws -> [String: String] {
|
|
let manager = FileManager.default
|
|
guard let walker = manager.enumerator(atPath: root.path) else { return [:] }
|
|
var files: [String: String] = [:]
|
|
for case let relative as String in walker where relative.hasSuffix(BoardLoader.indexFileName) {
|
|
let data = try Data(contentsOf: root.appendingPathComponent(relative))
|
|
files[relative] = String(decoding: data, as: UTF8.self)
|
|
}
|
|
return files
|
|
}
|
|
}
|