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 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"). /// /// ### 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. /// /// ### 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 moves 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`, /// in Application Support), where it would sit for good as an unavailable row pointing at a /// directory that no longer exists. Tying it to the same flag rather than to a second argument is /// deliberate: the two 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. 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 the audit suite passes (`KanbanUITests/AccessibilityAuditTests.swift`). /// /// `--`-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 /// `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. static func isFixtureLaunch(arguments: [String]) -> Bool { arguments.contains(fixtureFlag) } /// The running process's answer to the same question. static var isFixtureLaunch: Bool { isFixtureLaunch(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 Application /// Support, which is the whole point (see the type's note). static var registryStorageURL: URL { scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false) } /// The 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). static var fixtureBoardURL: URL { scratchRoot.appendingPathComponent("\(boardTitle).kanban", isDirectory: true) } /// Wipes and recreates the scratch root, and answers the registry URL to build the app model /// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry. /// /// **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 fixture 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"]`. 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: - Materialization /// Builds the fixture board and answers its URL — every write through `BoardWriter`, in the order /// a user would have produced them. /// /// 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 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 } /// 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) } }