import Foundation import os // MARK: - The launch plan /// What the app does with its first run-loop turn — decided once, in `KanbanApp.init()`, and never /// re-derived (02-architecture.md § Launch and window lifecycle). /// /// It exists because there are now **three** answers to "which window appears at launch", not two: /// welcome, the registry's restoration pass, and — for the accessibility audit suite /// (10-accessibility.md ▸ Verification) — a synthetic board the app builds for itself. A `Bool` held /// two of them; a third would have meant two booleans and an implicit precedence between them, which /// is exactly the shape that grows a launch bug nobody can reproduce. /// /// The decision is a pure function of three facts, so it is provable without a `UserDefaults` domain, /// a live registry, or a running app — `AppModel.shouldRestoreAtLaunch`'s own reason for being pulled /// out of `App.init` in the first place, applied one level up. enum LaunchPlan: Equatable, Sendable { /// Nothing to restore and no fixture asked for: the welcome window, which is the ordinary /// first-launch and tidied-away case. case welcome /// The registry's flagged boards reopen (`BoardRegistry.restorables()`). case restoreBoards /// The UI-test fixture board is built and opened. See `UITestLaunch`. case uiTestFixture /// **The fixture wins outright**, and that is the whole precedence rule: a UI-test launch must /// never reopen the developer's own boards, both because the audit needs a board whose contents /// the test knows and because a test run has no business touching real documents. static func decide( isUITestFixtureLaunch: Bool, restorePreference: Bool, hasRestorables: Bool ) -> LaunchPlan { if isUITestFixtureLaunch { return .uiTestFixture } return AppModel.shouldRestoreAtLaunch( preference: restorePreference, hasRestorables: hasRestorables ) ? .restoreBoards : .welcome } /// Whether the throwaway bootstrap window is presented at launch — everything except the plain /// welcome case, since both other plans have to open windows and only a view can do that /// (`RestoreBootstrapView`'s own reason for wearing a window). var presentsBootstrap: Bool { self != .welcome } } // MARK: - UITestLaunch /// **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 /// /// The obvious shape — the UI test writes a board to a temp folder and passes its path — **cannot /// work here, and the reason is the sandbox**. `Kanban.entitlements` grants /// `files.user-selected.read-write` and nothing else, so a path arriving on the command line is a /// path the app may not read: there is no open panel behind it and no bookmark for it. The app can /// only reach files it was granted, files it ships, and its own container. /// /// So the flag carries no payload and the app builds the board **inside its own container** /// (`NSTemporaryDirectory()`, which sandboxes to `…/Containers/dev.rzen.indie.Kanban/Data/tmp`), /// through the ordinary `BoardWriter` — the app's single write door (02-architecture.md § Layering). /// Nothing here knows the storage format: it calls `createBoard`, `createLane`, `createCard`, /// `writeBody`, `importAttachments` and `deleteCardToTrash` exactly as the board window does, so the /// 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 /// only when `--ui-test-fixture-board` is on the command line. No document open, no URL scheme and no /// menu item can produce it; a shipped app never runs a line of this. It is compiled into the release /// binary anyway rather than hidden behind `#if DEBUG`, because the thing being audited must be the /// app that ships — an accessibility pass over a differently-compiled binary is a pass over a /// different app. /// /// ### What the flag also switches off /// /// **The registry and the clipboard's staging store move into the scratch directory** with the board. /// Without that, every audit run would stamp a temp folder into the user's real recents list /// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a /// directory that no longer exists — and its launch sweep would collect the user's real staged copy /// (`ClipboardStore.defaultStagingRoot`). Both of those homes are now the **shared App Group /// container** (12-editions.md ▸ Distribution, ruled 2026-07-29), so each of those side effects would /// land on the sibling edition as well as this one. Tying them to the same flag rather than to separate /// arguments is deliberate: they are one decision — "this launch is synthetic" — and a second argument /// is a second chance to apply only half of it. /// /// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the /// three app-wide scalars (`AppPreferences`) into the real domain — the group's shared suite since the /// same ruling. They are a window size, a restore toggle this launch never consults, and the /// quick-style recents list — no documents, nothing destructive, and redirecting a defaults domain from /// inside the process is not something the platform actually supports. It is stated rather than /// fixed. enum UITestLaunch { private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch") // MARK: - The flag /// 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 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. 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. static var isFixtureLaunch: Bool { 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. /// /// One folder rather than two loose paths so `prepareScratchDirectory()` can promise a clean /// start with a single `removeItem` — a fixture board and a registry that disagreed about which /// run they belonged to would be worse than either being stale. static var scratchRoot: URL { URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("LaneworkUITestFixture", isDirectory: true) } /// Where the fixture launch's registry lives — beside the board rather than in the shared App /// Group container, which is the whole point (see the type's note). static var registryStorageURL: URL { scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false) } /// Where the fixture launch's clipboard snapshots live, on the registry's terms and now for a /// sharper reason: the real staging root moved into the **shared** App Group container /// (12-editions.md ▸ Both editions installed), so an audit run's launch sweep would otherwise /// collect the developer's own staged copy — and the sibling edition's, since there is only one /// store now. Redirected by the same flag, because it is the same one decision. static var clipboardStagingRoot: URL { scratchRoot.appendingPathComponent("Clipboard", isDirectory: true) } /// 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 { fixtureBoardURL(for: .standard) } /// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience. /// Called once, from `KanbanApp.init()`, **before** the model reads its registry — which is also why /// the return value is discardable: that caller now names both redirected homes explicitly /// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here. /// /// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is /// only meaningful against a board whose contents the test knows — a previous run's leftovers /// (a card the test deleted, a lane it renamed) would make the next run's tree something nobody /// wrote down. Removal failures are logged and swallowed: the create below will fail loudly and /// visibly on welcome if the directory is genuinely unusable, and there is no launch this early /// that an alert could belong to. @discardableResult static func prepareScratchDirectory() -> URL { let root = scratchRoot do { try FileManager.default.removeItem(at: root) } catch CocoaError.fileNoSuchFile { // The ordinary first-run case, not a failure. } catch { logger.error("could not clear the UI-test scratch directory: \(error.localizedDescription, privacy: .public)") } do { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } catch { logger.error("could not create the UI-test scratch directory: \(error.localizedDescription, privacy: .public)") } return registryStorageURL } // MARK: - The standard board's shape /// 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 /// one container to walk *between*, and because the smoke script's cut-and-paste step needs a /// destination lane that is not the source. static let laneTitles = ["To Do", "Doing", "Done"] /// The card titles, per lane, in card order. /// /// **The first lane is deliberately the crowded one.** Two of the audit's riskiest claims are /// about a lane wide enough to lay its cards out in interior masonry columns — that VoiceOver /// reads them by `order` and not column-major (10-accessibility.md ▸ Logical order), and that the /// `accessibilitySortPriority`-inside-a-`Layout` mechanism holding that up actually survives — and /// neither is observable in a lane with one card in it. static let cardTitles: [[String]] = [ ["Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit"], ["Write the smoke script"], ["Wire the launch fixture"], ] /// The card that gets the rich body, named by `(lane, card)` index — the one the card-window /// audits open. /// /// It carries the structures 10-accessibility.md makes claims about ("Preview renders to the /// accessibility tree as structured text — headings navigable by rotor, lists and tables read as /// such; task-list checkboxes are real accessible checkboxes"), so the Preview audit has /// something to audit and the Edit and raw-source audits open a document with real content in it /// rather than an empty text view. static let richCardIndex = (lane: 1, card: 0) /// The rich card's body. Every element in it is one 10-accessibility.md names: two heading /// levels for the rotor, a bulleted list, a task list (live checkboxes), a table, a fenced code /// block, a link, and an image with alt text ("body images use Markdown alt text when present, /// else the filename" — the file need not exist for the alt text to be the thing under audit). static let richCardBody = """ ## What this card is for It is the accessibility audit's specimen: every structure 10-accessibility.md makes a claim \ about appears once, so Preview has something to render into the tree. ### Structures - A bulleted list item - A second one, with a [link](https://example.com) in it - [ ] An unchecked task - [x] A checked task | Surface | Audited | | --- | --- | | Board | Yes | | Card window | Yes | ```swift let audit = try app.performAccessibilityAudit() ``` ![A diagram of the board's accessibility tree](attachments/tree.png) """ /// The attachment the rich card carries, so the card face shows a paperclip, the card element's /// value says "1 attachment" (`AccessibilityPhrases.cardValue`), and the card window's sidebar /// has a real attachment row to audit. static let attachmentName = "notes.txt" private static let attachmentBody = """ The audit fixture's attachment. Its only job is to exist, so the attachments section has a row. """ /// The card that is deleted into `.trash/`, named by `(lane, card)` index. /// /// A trash with something in it is the only way the trash-shown audit reaches the elements /// 10-accessibility.md specifies for it — ordinary card elements whose context menu offers Delete /// 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 `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. /// /// **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] = [] for title in laneTitles { let id = try BoardWriter.createLane(inBoard: root, title: title) laneURLs.append(root.appendingPathComponent(id.rawValue, isDirectory: true)) } var cardURLs: [[URL]] = [] for (laneIndex, titles) in cardTitles.enumerated() { var lane: [URL] = [] for title in titles { let id = try BoardWriter.createCard(inLane: laneURLs[laneIndex], title: title) lane.append(laneURLs[laneIndex].appendingPathComponent(id.rawValue, isDirectory: true)) } cardURLs.append(lane) } let richCard = cardURLs[richCardIndex.lane][richCardIndex.card] try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody) try importFixtureAttachment(into: richCard) // The delete goes last so the trashed card's identity is one the lanes above have already // finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly // what a user's ⌘⌫ would have put there, stamps and `order` included. let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card] try BoardWriter.deleteCardToTrash( at: doomed, inBoard: root, order: Ranks.append(toVisible: [] as [Double]) ) 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. private static func importFixtureAttachment(into cardFolder: URL) throws { let source = scratchRoot.appendingPathComponent(attachmentName, isDirectory: false) try attachmentBody.write(to: source, atomically: true, encoding: .utf8) _ = try BoardWriter.importAttachments([source], intoCard: cardFolder) try? FileManager.default.removeItem(at: source) } }