import AppKit import CoreGraphics import Darwin import Foundation import ImageIO import PDFKit import ShotdeckCore /// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`. /// Posts synthetic mouse events through `NSApp.postEvent` only — never CGEventPost/taps. @MainActor enum PickerSelfTest { private static let pointA = NSPoint(x: 200, y: 300) private static let pointB = NSPoint(x: 600, y: 600) private static let dragSteps = 6 /// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the /// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s. static func runIfRequested() { guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"], !raw.isEmpty else { return } let output = URL(fileURLWithPath: raw, isDirectory: true) // Hop onto a plain main-queue turn after NSApp starts. Nested run loops // from a Swift Task do not drain NSApp's event queue. DispatchQueue.main.async { MainActor.assumeIsolated { execute(outputDirectory: output) } } } private static func execute(outputDirectory: URL) { do { try FileManager.default.createDirectory( at: outputDirectory, withIntermediateDirectories: true ) } catch { fail(expected: expectedLabel(), got: "could not create output dir: \(error)") } guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) }) ?? NSScreen.screens.first else { fail(expected: expectedLabel(), got: "no NSScreen") } let appKitRect = CGRect( x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y), width: abs(pointB.x - pointA.x), height: abs(pointB.y - pointA.y) ).intersection(screen.frame) let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen) let picker = RegionPickerController() var result: CaptureRegion?? picker.pick { region in result = .some(region) } guard let overlay = overlayWindow(containing: pointA) else { fail(expected: format(expected.rect), got: "no overlay window after pick()") } overlay.makeKey() var eventNumber = 1 func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) { let locationInWindow = overlay.convertPoint(fromScreen: screenPoint) guard let event = NSEvent.mouseEvent( with: type, location: locationInWindow, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: overlay.windowNumber, context: nil, eventNumber: eventNumber, clickCount: clickCount, pressure: 1 ) else { fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil") } eventNumber += 1 NSApp.postEvent(event, atStart: false) // Local monitors run during NSApp.sendEvent (production dispatch), // not during nextEvent dequeue. Drive that same path here. NSApp.sendEvent(event) } post(.leftMouseDown, at: pointA, clickCount: 1) for step in 1...(dragSteps / 2) { post(.leftMouseDragged, at: interpolate(step), clickCount: 0) } writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png")) for step in ((dragSteps / 2) + 1)...dragSteps { post(.leftMouseDragged, at: interpolate(step), clickCount: 0) } post(.leftMouseUp, at: pointB, clickCount: 1) guard let wrapped = result else { fail(expected: format(expected.rect), got: "completion never fired") } guard let got = wrapped else { fail(expected: format(expected.rect), got: "nil") } if got.rect != expected.rect { fail(expected: format(expected.rect), got: format(got.rect)) } print("PICKER-SELFTEST PASS rect=\(format(got.rect))") fflush(stdout) runRegionPersistPhase() // Hop off this MainActor job so the SEND-TRUTH Task can run; do not // exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then // chains to UPDATE-SELFTEST (or exits if that phase is not requested). runSendTruthPhase() } /// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through /// `AppModel.loadPersistedRegion()` (the same path init uses), then restores whatever /// value was stored before so a real picked region is untouched. private static func runRegionPersistPhase() { let defaults = UserDefaults.standard let previous = defaults.data(forKey: CaptureRegion.defaultsKey) let known = CaptureRegion( displayID: CGMainDisplayID(), rect: CGRect(x: 10, y: 10, width: 100, height: 100), capturedScale: 2.0 ) var failure: String? if let encoded = try? JSONEncoder().encode(known) { defaults.set(encoded, forKey: CaptureRegion.defaultsKey) if let loaded = AppModel.loadPersistedRegion() { if loaded.rect != known.rect { failure = "expected=\(format(known.rect)) got=\(format(loaded.rect))" } } else { failure = "loadPersistedRegion returned nil" } } else { failure = "could not encode CaptureRegion" } if let previous { defaults.set(previous, forKey: CaptureRegion.defaultsKey) } else { defaults.removeObject(forKey: CaptureRegion.defaultsKey) } if let failure { print("REGION-PERSIST FAIL \(failure)") fflush(stdout) exit(1) } print("REGION-PERSIST PASS") fflush(stdout) } /// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet. /// Fail path must leave the session open in the temp spool; success path archives /// and mints a fresh empty session. Scheduled as a new MainActor job because this /// function is called from inside `execute()` — a nested run-loop wait would never /// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting. private static func runSendTruthPhase() { Task { @MainActor in do { try await executeSendTruth() print("SEND-TRUTH PASS") fflush(stdout) if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() { exit(0) } } catch { print("SEND-TRUTH FAIL \(error)") fflush(stdout) exit(1) } } } private static func executeSendTruth() async throws { let fm = FileManager.default let root = fm.temporaryDirectory .appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true) defer { try? fm.removeItem(at: root) } let paths = try AppSupportPaths( root: root, outbox: root.appendingPathComponent("outbox", isDirectory: true), watchFolder: root.appendingPathComponent("watch", isDirectory: true) ) let ledger = try ReturnLedger(paths: paths) let model = AppModel( paths: paths, spool: try SpoolStore(paths: paths), composer: PDFComposer(), capturer: ScreenCapturer(), hotkeys: HotkeyCenter(), picker: RegionPickerController(), ledger: ledger, watcher: ReturnWatcher(paths: paths, ledger: ledger) ) model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder) let png = try makeTinyPNGData() _ = try await model.spool.append( pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date() ) model.replaceSession(try await model.spool.currentSession()) let openID = model.session.id guard !model.session.isEmpty else { sendTruthFail("seeded session was empty") } let pending = try await model.composePDFForSend(outbox: model.outboxURL, transport: .airDrop) guard fm.fileExists(atPath: pending.fileURL.path) else { sendTruthFail("PDF was not written") } model.handleDidFailToShareItems(fileName: pending.fileName) let still = try await model.spool.currentSession() guard still.id == openID, !still.isEmpty, still.state == .open else { sendTruthFail("fail path archived or replaced the session") } let spoolDir = paths.sessionDirectory(openID) guard fm.fileExists(atPath: spoolDir.path) else { sendTruthFail("fail path: session missing from temp spool") } guard let status = model.statusLine, status.contains("nothing was sent") else { sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")") } await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount) let fresh = try await model.spool.currentSession() guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else { sendTruthFail("success path did not mint a fresh empty session") } let archived = try await model.spool.archivedSessions() guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else { sendTruthFail("success path did not archive the session") } let archiveDir = paths.archiveDirectory(openID) guard fm.fileExists(atPath: archiveDir.path) else { sendTruthFail("success path: archive dir missing") } guard !fm.fileExists(atPath: spoolDir.path) else { sendTruthFail("success path: session still in spool") } } private static func makeTinyPNGData() throws -> Data { let width = 64 let height = 48 let colorSpace = CGColorSpaceCreateDeviceRGB() guard let context = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { sendTruthFail("could not create PNG context") } context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1) context.fill(CGRect(x: 0, y: 0, width: width, height: height)) guard let image = context.makeImage() else { sendTruthFail("could not make CGImage") } let buffer = NSMutableData() guard let destination = CGImageDestinationCreateWithData( buffer, "public.png" as CFString, 1, nil ) else { sendTruthFail("could not create PNG destination") } CGImageDestinationAddImage(destination, image, nil) guard CGImageDestinationFinalize(destination) else { sendTruthFail("could not finalize PNG") } return buffer as Data } private static func sendTruthFail(_ reason: String) -> Never { print("SEND-TRUTH FAIL \(reason)") fflush(stdout) exit(1) } /// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via /// `checkNow`, then `installStaged` into the env dir — never `/Applications`. /// Returns true when the async phase was scheduled (it calls `exit` itself). @discardableResult private static func startUpdateSelfTestIfRequested() -> Bool { guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"], !raw.isEmpty else { return false } let output = URL(fileURLWithPath: raw, isDirectory: true) Task { @MainActor in do { try await runUpdateSelfTest(outputDirectory: output) print("UPDATE-SELFTEST PASS version=99.0.0") fflush(stdout) if !startOneDriveSelfTestIfRequested() { exit(0) } } catch let error as UpdateSelfTestError { updateFail(error.description) } catch { updateFail(String(describing: error)) } } return true } private static func runUpdateSelfTest(outputDirectory: URL) async throws { let fm = FileManager.default try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true) guard let sourceApp = ownAppBundleURL() else { throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))") } let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true) if fm.fileExists(atPath: payload.path) { try fm.removeItem(at: payload) } try fm.createDirectory(at: payload, withIntermediateDirectories: true) let fakeApp = payload.appendingPathComponent("Redline.app") try fm.copyItem(at: sourceApp, to: fakeApp) let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist") let plistData = try Data(contentsOf: plistURL) guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else { throw UpdateSelfTestError.detail("could not parse copied Info.plist") } plist["CFBundleShortVersionString"] = "99.0.0" let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try rewritten.write(to: plistURL) let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip") if fm.fileExists(atPath: zipURL.path) { try fm.removeItem(at: zipURL) } try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path]) let zipData = try Data(contentsOf: zipURL) let hex = UpdateChecker.sha256Hex(zipData) let appcastURL = outputDirectory.appendingPathComponent("appcast.json") let appcast: [String: String] = [ "version": "99.0.0", "zipURL": zipURL.absoluteString, "sha256": hex, "notes": "UPDATE-SELFTEST", ] let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys]) try appcastData.write(to: appcastURL) let defaults = UserDefaults.standard let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey) defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey) defer { if let previous { defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey) } else { defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey) } } let (model, isolatedRoot) = try makeIsolatedUpdateModel() defer { try? fm.removeItem(at: isolatedRoot) } await model.updateChecker.checkNow() guard model.updateAvailable?.version == "99.0.0" else { throw UpdateSelfTestError.detail( "updateAvailable=\(model.updateAvailable?.version ?? "nil")" ) } guard let staged = model.updateChecker.stagedAppURL else { throw UpdateSelfTestError.detail("staged payload missing") } guard staged.lastPathComponent == "Redline.app" else { throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)") } let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck") guard fm.fileExists(atPath: stagedExe.path) else { throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing") } let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true) if fm.fileExists(atPath: targetRoot.path) { try fm.removeItem(at: targetRoot) } let target = targetRoot.appendingPathComponent("Redline.app") model.updateChecker.installStaged(to: target) let installedPlist = target.appendingPathComponent("Contents/Info.plist") guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any], let installedVersion = installed["CFBundleShortVersionString"] as? String else { throw UpdateSelfTestError.detail("installed Info.plist unreadable") } guard installedVersion == "99.0.0" else { throw UpdateSelfTestError.detail("installed version \(installedVersion)") } } private static func ownAppBundleURL() -> URL? { let bundle = Bundle.main.bundleURL if bundle.pathExtension == "app" { return bundle } let up3 = bundle .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() if up3.pathExtension == "app" { return up3 } return nil } private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) { let root = FileManager.default.temporaryDirectory .appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true) let paths = try AppSupportPaths( root: root, outbox: root.appendingPathComponent("outbox", isDirectory: true), watchFolder: root.appendingPathComponent("watch", isDirectory: true) ) let ledger = try ReturnLedger(paths: paths) let model = AppModel( paths: paths, spool: try SpoolStore(paths: paths), composer: PDFComposer(), capturer: ScreenCapturer(), hotkeys: HotkeyCenter(), picker: RegionPickerController(), ledger: ledger, watcher: ReturnWatcher(paths: paths, ledger: ledger) ) return (model, root) } private static func runDitto(arguments: [String]) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") process.arguments = arguments let err = Pipe() process.standardError = err process.standardOutput = Pipe() try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" throw UpdateSelfTestError.detail("ditto failed: \(message)") } } private static func updateFail(_ detail: String) -> Never { print("UPDATE-SELFTEST FAIL \(detail)") fflush(stdout) exit(1) } /// Phase 5: proves the OneDrive transport end to end against a REAL sync root — /// resolved at runtime via `OneDriveLocator.syncRoots()`, never a hardcoded path, so /// this runs correctly on any Mac/account that has OneDrive signed in (MMD-named /// root preferred, same as production). Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` /// when chained after PICKER/SEND-TRUTH/UPDATE-SELFTEST — the exact pattern /// `startUpdateSelfTestIfRequested` uses for its own env var. Returns true when the /// async phase was scheduled (it calls `exit` itself). @discardableResult private static func startOneDriveSelfTestIfRequested() -> Bool { guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else { return false } runOneDriveSelfTestAndExit() return true } /// Entry point for running ONLY this phase, bypassing the on-screen picker chain /// entirely. The harness has no other per-phase selector, so this is the escape /// hatch: `REDLINE_SELFTEST_PHASE=onedrive`. static func runOneDriveOnlyIfRequested() { guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else { return } // Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so // AppKit/PDFKit calls inside the phase are not racing app launch. DispatchQueue.main.async { MainActor.assumeIsolated { runOneDriveSelfTestAndExit() } } } private static func runOneDriveSelfTestAndExit() { // Never a false PASS: no real OneDrive sync root on this machine/account is a // SKIP (still non-zero exit), not silently treated as passing. guard let syncRoot = OneDriveLocator.syncRoots().first else { print("ONEDRIVE-SELFTEST SKIP no OneDrive sync root") fflush(stdout) exit(1) } Task { @MainActor in do { let folder = try await executeOneDriveSelfTest(syncRoot: syncRoot) print("ONEDRIVE-SELFTEST PASS path=\(folder.path)") fflush(stdout) exit(0) } catch let error as OneDriveSelfTestError { oneDriveFail(error.description) } catch { oneDriveFail(String(describing: error)) } } } /// Sub-step 1: builds a session, sends it through the OneDrive branch of /// `send(anchor: nil)` against a NEW folder under `syncRoot`, confirms the watcher /// does NOT report the freshly-written unmarked PDF as a return, then adds a real /// PDFKit ink annotation in place (what the iPad does) and confirms the watcher now /// reports it as commented. /// /// Sub-step 1b: rapid transport toggling (chooseTransport(.airDrop) immediately /// followed by chooseTransport(.oneDrive), no await between them) must still end /// with the watcher pointed at the OneDrive folder — proves the generation-guarded /// reconcile in chooseTransport/chooseOneDriveFolder (SettingsView.swift) really /// does let the last choice win instead of an earlier, superseded call applying its /// stale folder after a later one already won. /// /// Sub-step 1c: the OTHER race — a toggle immediately followed by Send, with no /// sleep at all before send() runs. Proves send() awaits `pendingReconcileTask` /// before snapshotting transport/folder: a freshly-sent, still-unmarked PDF must /// never be reported as an already-returned document (which is exactly what would /// happen if send() raced ahead while recordUncommented was still `true`, stale /// from the .airDrop leg of the toggle). /// /// Sub-step 2: relaunch simulation — the exact BLOCKER scenario this phase exists to /// catch. OneDrive is still persisted in defaults from sub-step 1; builds a FRESH /// model the same way the real app launches (`AppDelegate.makeLaunchModel()` itself, /// not a reimplementation), bootstraps it, then marks a PDF in the folder in place — /// the relaunched watcher must report it. A temp app-support root keeps this off the /// real ~/Library/Application Support/Shotdeck. /// /// Never deletes anything under OneDrive — the created folder and PDFs are left in /// place for Ben to inspect / for the real iPad round trip. private static func executeOneDriveSelfTest(syncRoot: URL) async throws -> URL { let fm = FileManager.default // UserDefaults.standard is the ONLY defaults instance send()/TransportSettings // actually read at runtime (there is no defaults-threading through AppModel), so // "isolated" here means snapshot-and-restore around the real keys — the same // pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey. let defaults = UserDefaults.standard let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey) let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey) defer { if let previousTransport { defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey) } else { defaults.removeObject(forKey: TransportSettings.transportDefaultsKey) } if let previousFolder { defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey) } else { defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey) } } let stamp = DubaiTime.fileStamp(Date()) let selftestFolder = syncRoot.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true) try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true) TransportSettings.setTransport(.oneDrive, defaults: defaults) TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults) // Local spool root only — the outbox/watch folder is the real OneDrive folder. let spoolRoot = fm.temporaryDirectory .appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true) defer { try? fm.removeItem(at: spoolRoot) } let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder) let ledger = try ReturnLedger(paths: paths) let watcher = ReturnWatcher(paths: paths, ledger: ledger) await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`. let model = AppModel( paths: paths, spool: try SpoolStore(paths: paths), composer: PDFComposer(), capturer: ScreenCapturer(), hotkeys: HotkeyCenter(), picker: RegionPickerController(), ledger: ledger, watcher: watcher ) model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder) model.setTransport(.oneDrive) let png = try makeTinyPNGData() _ = try await model.spool.append( pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date() ) model.replaceSession(try await model.spool.currentSession()) guard !model.session.isEmpty else { throw OneDriveSelfTestError.detail("seeded session was empty") } await model.send(anchor: nil) guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else { throw OneDriveSelfTestError.detail( "status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")" ) } guard model.session.isEmpty else { throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send") } let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? [] guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else { throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)") } // Unmarked so far: the watcher must not treat it as a return. let beforeMarkup = try await watcher.scanNow() guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else { throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow") } let commentedBefore = try await ledger.commented() guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else { throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger") } // What the iPad does: mark it up in place with a real ink annotation, then save. try addInkMark(to: pdfURL) let afterMarkup = try await watcher.scanNow() guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else { throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow") } let commentedAfter = try await ledger.commented() guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else { throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented") } // Sub-step 1b: rapid toggle race — see the doc comment above this function. model.chooseTransport(.airDrop) model.chooseTransport(.oneDrive) // immediately superseding the call above // The generation guard itself is what's under test, not this wait — it just // gives the (already-guarded) reconcile Task a moment to settle either way. try await Task.sleep(for: .milliseconds(500)) guard model.transport == .oneDrive else { throw OneDriveSelfTestError.detail( "rapid toggle: model.transport ended as \(model.transport), expected .oneDrive" ) } let racePDFURL = selftestFolder.appendingPathComponent("Redline-race-\(stamp).pdf") try writeUnmarkedRedlinePDF(to: racePDFURL) try addInkMark(to: racePDFURL) let raceFound = try await model.watcher.scanNow() guard raceFound.first(where: { $0.fileURL == racePDFURL })?.isCommented == true else { throw OneDriveSelfTestError.detail( "rapid toggle: watcher did not end up watching \(selftestFolder.path) — an earlier, superseded chooseTransport call won" ) } // Sub-step 1c: toggle-then-immediate-send race — see the doc comment above // this function. No sleep here: this IS the exact race window finding #3 // exists to close, so send() itself must wait out the pending reconcile. let racePNG = try makeTinyPNGData() _ = try await model.spool.append( pngData: racePNG, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date() ) model.replaceSession(try await model.spool.currentSession()) guard !model.session.isEmpty else { throw OneDriveSelfTestError.detail("toggle-then-send: re-seeded session was empty") } let knownBeforeToggleSend = Set( ((try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []) .map(\.lastPathComponent) ) model.chooseTransport(.airDrop) model.chooseTransport(.oneDrive) // immediately superseding, no sleep before send() await model.send(anchor: nil) guard let toggleSendStatus = model.statusLine, toggleSendStatus.hasPrefix("Saved to OneDrive") else { throw OneDriveSelfTestError.detail( "toggle-then-send: status was \(model.statusLine ?? "nil"), expected 'Saved to OneDrive'" ) } guard model.session.isEmpty else { throw OneDriveSelfTestError.detail("toggle-then-send: session was not archived") } let filesAfterToggleSend = (try? fm.contentsOfDirectory( at: selftestFolder, includingPropertiesForKeys: nil )) ?? [] guard let toggleSendPDFURL = filesAfterToggleSend.first(where: { $0.pathExtension.lowercased() == "pdf" && !knownBeforeToggleSend.contains($0.lastPathComponent) }) else { throw OneDriveSelfTestError.detail("toggle-then-send: no new PDF found in \(selftestFolder.path)") } // The freshly-sent PDF is UNMARKED. If send() had raced ahead of the pending // reconcile, recordUncommented could still have been (stale) true, and this // scan would wrongly report it as already returned. let scanAfterToggleSend = try await model.watcher.scanNow() guard !scanAfterToggleSend.contains(where: { $0.fileURL == toggleSendPDFURL }) else { throw OneDriveSelfTestError.detail( "toggle-then-send: freshly-sent unmarked PDF at \(toggleSendPDFURL.path) was reported as returned — send() raced ahead of the pending reconcile" ) } // Sub-step 2: relaunch simulation — see the doc comment above this function. let relaunchAppSupportRoot = fm.temporaryDirectory .appendingPathComponent("shotdeck-onedrive-relaunch-\(UUID().uuidString)", isDirectory: true) defer { try? fm.removeItem(at: relaunchAppSupportRoot) } let relaunchModel = AppDelegate.makeLaunchModel(appSupportRoot: relaunchAppSupportRoot) guard relaunchModel.transport == .oneDrive else { throw OneDriveSelfTestError.detail( "relaunch: model transport was \(relaunchModel.transport), expected .oneDrive" ) } guard relaunchModel.watchFolderURL.path == selftestFolder.path else { throw OneDriveSelfTestError.detail( "relaunch: model watchFolderURL was \(relaunchModel.watchFolderURL.path), expected \(selftestFolder.path) — this is the exact BLOCKER this phase guards against" ) } await relaunchModel.bootstrap() let relaunchPDFURL = selftestFolder.appendingPathComponent("Redline-relaunch-\(stamp).pdf") try writeUnmarkedRedlinePDF(to: relaunchPDFURL) try addInkMark(to: relaunchPDFURL) let relaunchFound = try await relaunchModel.watcher.scanNow() guard relaunchFound.first(where: { $0.fileURL == relaunchPDFURL })?.isCommented == true else { throw OneDriveSelfTestError.detail( "relaunch: watcher did not report the marked PDF at \(relaunchPDFURL.path) as returned — it was watching the wrong folder after relaunch" ) } await relaunchModel.watcher.stop() return selftestFolder } /// Adds a real PDFKit ink annotation to the PDF at `url` in place and saves it — /// exactly what the iPad does when marking up a page. private static func addInkMark(to url: URL) throws { guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { throw OneDriveSelfTestError.detail("could not reopen \(url.path) to annotate it") } let ink = PDFAnnotation( bounds: CGRect(x: 20, y: 20, width: 60, height: 60), forType: .ink, withProperties: nil ) let stroke = NSBezierPath() stroke.move(to: NSPoint(x: 20, y: 20)) stroke.line(to: NSPoint(x: 80, y: 80)) ink.add(stroke) page.addAnnotation(ink) guard document.write(to: url) else { throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(url.path)") } } /// Writes a fresh, unmarked, single-page "Redline"-creator PDF straight to `url` — /// standing in for a PDF that has just landed in the watch folder, before any /// human mark. Used by the rapid-toggle and relaunch sub-steps, which don't need to /// exercise send()/composePDFForSend() again (sub-step 1 already does). private static func writeUnmarkedRedlinePDF(to url: URL) throws { let document = PDFDocument() let page = PDFPage() page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox) document.insert(page, at: 0) document.documentAttributes = [ PDFDocumentAttribute.creatorAttribute: "Redline", PDFDocumentAttribute.subjectAttribute: UUID().uuidString, ] guard document.write(to: url) else { throw OneDriveSelfTestError.detail("could not write \(url.path)") } } private static func oneDriveFail(_ detail: String) -> Never { print("ONEDRIVE-SELFTEST FAIL \(detail)") fflush(stdout) exit(1) } private static func interpolate(_ step: Int) -> NSPoint { let t = CGFloat(step) / CGFloat(dragSteps) return NSPoint( x: pointA.x + (pointB.x - pointA.x) * t, y: pointA.y + (pointB.y - pointA.y) * t ) } private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? { let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow } return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first } private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) { guard let view = overlay.contentView else { fail(expected: expectedLabel(), got: "overlay has no contentView") } overlay.layoutIfNeeded() view.layoutSubtreeIfNeeded() view.display() let bounds = view.bounds guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed") } view.cacheDisplay(in: bounds, to: rep) guard let png = rep.representation(using: .png, properties: [:]) else { fail(expected: expectedLabel(), got: "PNG encode failed") } do { try png.write(to: url) } catch { fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)") } print(url.path) fflush(stdout) } private static func expectedLabel() -> String { format(CGRect( x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y), width: abs(pointB.x - pointA.x), height: abs(pointB.y - pointA.y) )) } private static func format(_ rect: CGRect) -> String { "(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))" } private static func fail(expected: String, got: String) -> Never { print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)") fflush(stdout) exit(1) } } private enum UpdateSelfTestError: Error, CustomStringConvertible { case detail(String) var description: String { switch self { case .detail(let s): return s } } } private enum OneDriveSelfTestError: Error, CustomStringConvertible { case detail(String) var description: String { switch self { case .detail(let s): return s } } }