diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index b30c703..e4cbf27 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -3,6 +3,7 @@ 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`. @@ -173,7 +174,7 @@ enum PickerSelfTest { try await executeSendTruth() print("SEND-TRUTH PASS") fflush(stdout) - if !startUpdateSelfTestIfRequested() { + if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() { exit(0) } } catch { @@ -315,7 +316,9 @@ enum PickerSelfTest { try await runUpdateSelfTest(outputDirectory: output) print("UPDATE-SELFTEST PASS version=99.0.0") fflush(stdout) - exit(0) + if !startOneDriveSelfTestIfRequested() { + exit(0) + } } catch let error as UpdateSelfTestError { updateFail(error.description) } catch { @@ -473,6 +476,194 @@ enum PickerSelfTest { exit(1) } + /// Real sync root this Mac has; the phase proves the transport against the actual + /// OneDrive file provider, never a fake home tree (that is what + /// OneDriveLocatorTests in ShotdeckCoreTests are for). + private static let realOneDriveSyncRoot = URL( + fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP", + isDirectory: true + ) + + /// Phase 5: proves the OneDrive transport end to end against the real sync root. + /// 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() { + Task { @MainActor in + do { + let folder = try await executeOneDriveSelfTest() + print("ONEDRIVE-SELFTEST PASS path=\(folder.path)") + fflush(stdout) + exit(0) + } catch let error as OneDriveSelfTestError { + oneDriveFail(error.description) + } catch { + oneDriveFail(String(describing: error)) + } + } + } + + /// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)` + /// against a NEW folder under the real OneDrive sync root, 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. Never deletes anything under OneDrive — the created folder and + /// PDF are left in place for Ben to inspect / for the real iPad round trip. + private static func executeOneDriveSelfTest() async throws -> URL { + let fm = FileManager.default + guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else { + throw OneDriveSelfTestError.detail( + "real OneDrive sync root not found at \(realOneDriveSyncRoot.path)" + ) + } + + // 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 = realOneDriveSyncRoot + .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. + guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else { + throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.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: pdfURL) else { + throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)") + } + + 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") + } + + return selftestFolder + } + + 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( @@ -538,3 +729,12 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible { } } } + +private enum OneDriveSelfTestError: Error, CustomStringConvertible { + case detail(String) + var description: String { + switch self { + case .detail(let s): return s + } + } +} diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 3b61e42..e2f5fff 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -7,6 +7,9 @@ import ShotdeckCore if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil { MainActor.assumeIsolated { PanelSnapshot.runIfRequested() } } +if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" { + MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() } +} if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil { MainActor.assumeIsolated { PickerSelfTest.runIfRequested() } }