summaryrefslogtreecommitdiff
path: root/test/macos_e2e.swift
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 09:58:31 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commiteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch)
treecc993ad239451adb6f23645ee5ca001802832ed0 /test/macos_e2e.swift
parent38e9919a9ea9055538409b388d580c4e4c838434 (diff)
downloadpardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz
pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'test/macos_e2e.swift')
-rw-r--r--test/macos_e2e.swift983
1 files changed, 983 insertions, 0 deletions
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
new file mode 100644
index 00000000..ba0c48f0
--- /dev/null
+++ b/test/macos_e2e.swift
@@ -0,0 +1,983 @@
+// pardes-macos-e2e: drive the real AppKit shell offscreen with the same kind of
+// .snap script the tty backend uses, and diff the captures against goldens.
+//
+// zig build macos-e2e -Dplatform=macos run every script
+// zig build macos-e2e -Dplatform=macos -- --update regenerate goldens
+// zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/boot.snap
+//
+// This is the only test the Swift half has. src/macos.zig's ABI guard proves
+// the header and the Zig side agree, and it runs on Linux; nothing there ever
+// constructs a PardesView, translates a gesture, or asks CoreText to paint a
+// glyph. So the harness links the SHIPPING view — not a stub, and with no test
+// hook bolted onto the app — into an offscreen window and calls the same public
+// entry points the NSEvent overrides call. What a script exercises is what a
+// hand exercises.
+//
+// The output format is test/snapshot.zig's, deliberately byte-identical in
+// shape (`== snap <label> grid=CxR cursor=X,Y` then one `|`-prefixed row each,
+// right-trimmed), and the shared command spellings — wait, stable, text, key,
+// resize, snap — are its as well. Three shells render one core; a capture that
+// cannot be read beside the other two backends' captures is worth much less.
+//
+// The hermetic world under /tmp/pardes-macos-e2e is not optional. A golden
+// taken against the developer's $HOME records the developer's prompt, their
+// fish greeting and their config's builtins, and is then a golden for one
+// machine. Everything a shell reads is rebuilt per script before pardes_init.
+
+import AppKit
+import Darwin
+import Foundation
+
+/// Where each script's fake world is built. Fixed, not mkdtemp: the pane tag
+/// prints this path, so it lands in the goldens verbatim and must not move.
+private let workBase = "/tmp/pardes-macos-e2e"
+private let defaultScriptDir = "test/macos-snapshots"
+
+/// pardes_tick drains one round of pty output plus the effects it produced, and
+/// a shell writing steadily can keep it returning true for as long as it has
+/// something to say. The cap turns "pump until quiet" into a bounded loop;
+/// `wait` and `stable` are what actually wait.
+private let maxTicksPerPump = 64
+
+/// Poll granularity for the two waiting commands. Small enough that a
+/// `stable 400` has room to see several quiet slices, large enough not to spin.
+private let pollInterval: TimeInterval = 0.01
+
+private struct ScriptError: Error {
+ let message: String
+}
+
+/// What the two C callbacks reach through `userdata`. A class, because a C
+/// function pointer cannot capture: a pointer the runtime struct carries is the
+/// only way back into Swift state.
+private final class Host {
+ /// Set by `wakeup`, which runs on a pty reader thread. Nothing else in the
+ /// ABI may be touched from there, so this flag is the entire body — the
+ /// waiting loops read it to know output is still arriving, and that a quiet
+ /// slice does not yet mean the screen has settled.
+ var woke = false
+ /// The last text the core yanked. The app hands this to NSPasteboard; a
+ /// hermetic run must not, or a script would be asserting against — and
+ /// clobbering — whatever the developer last copied.
+ var clipboard: String?
+}
+
+/// One rendered frame, already flattened the way a capture wants it.
+private struct Frame {
+ let cols: Int
+ let rows: Int
+ let cursorX: Int
+ let cursorY: Int
+ /// One entry per row, right-trimmed of spaces.
+ let lines: [String]
+
+ var text: String { lines.joined(separator: "\n") }
+ var screen: String { lines.map { "|\($0)" }.joined(separator: "\n") }
+}
+
+private func trimTrailingSpaces(_ s: String) -> String {
+ var out = s
+ while out.hasSuffix(" ") { out.removeLast() }
+ return out
+}
+
+// ---------------------------------------------------------------- the driver
+
+/// Owns the window, the view and the core for the duration of one script.
+private final class Driver: PardesViewDelegate {
+ private let host = Host()
+ private var window: NSWindow?
+ private var view: PardesView?
+ private var booted = false
+ private var observer: NSObjectProtocol?
+ private var output = ""
+
+ /// The script's own file name, for error messages.
+ private let scriptName: String
+
+ init(scriptName: String) {
+ self.scriptName = scriptName
+ // The app answers this notification with pump(); so does the harness,
+ // and for the same reason — the view calls into the core and never
+ // ticks it, so an unpumped input is an input that visibly did nothing.
+ // Posting is synchronous, so this fires inside the view call, exactly
+ // as it does under NSApplication.
+ observer = NotificationCenter.default.addObserver(
+ forName: pardesDidInputNotification, object: nil, queue: nil
+ ) { [weak self] _ in
+ self?.pump()
+ }
+ }
+
+ deinit {
+ if let observer { NotificationCenter.default.removeObserver(observer) }
+ }
+
+ // MARK: - lifecycle
+
+ private func live() throws -> PardesView {
+ guard let view, booted else {
+ throw ScriptError(message: "no core yet — the script needs a `start <rows> <cols>` first")
+ }
+ return view
+ }
+
+ private func start(rows: Int, cols: Int) throws {
+ // The ABI is a singleton — pardes_init returns 1 while one is up — so a
+ // script that restarts must tear the old one down first.
+ shutdown()
+ // The grid is a pair of u16 all the way down, and CGFloat(cols) *
+ // cellWidth is a window someone has to allocate a backing store for. A
+ // typo in a script should say so, not trap in a UInt16 conversion.
+ guard rows > 0, cols > 0, rows <= 500, cols <= 1000 else {
+ throw ScriptError(message: "start wants a sane grid, not \(rows)x\(cols)")
+ }
+
+ // 14pt, the size AppDelegate opens the app at: the cell metrics decide
+ // the window's pixel size, and `draw` records that size.
+ let view = PardesView(fontSize: 14)
+ let size = NSSize(
+ width: CGFloat(cols) * view.cellWidth,
+ height: CGFloat(rows) * view.cellHeight)
+ // Borderless, and never ordered front. A titled window spends the rows
+ // under its title bar out of the content rect, and the grid the core
+ // boots at has to be the grid the script asked for.
+ let window = NSWindow(
+ contentRect: NSRect(origin: .zero, size: size),
+ styleMask: .borderless,
+ backing: .buffered,
+ defer: false)
+ window.isReleasedWhenClosed = false
+ window.contentView = view
+ view.setFrameSize(size)
+ self.window = window
+ self.view = view
+
+ let got = view.gridSize
+ guard got.cols == UInt16(cols), got.rows == UInt16(rows) else {
+ throw ScriptError(
+ message: "the view measured \(got.cols)x\(got.rows) cells, not the \(cols)x\(rows) the script asked for")
+ }
+
+ var runtime = pardes_runtime_s(
+ userdata: Unmanaged.passUnretained(host).toOpaque(),
+ wakeup: { ud in
+ // A pty reader thread. One flag and out — see Host.woke.
+ guard let ud else { return }
+ Unmanaged<Host>.fromOpaque(ud).takeUnretainedValue().woke = true
+ },
+ set_clipboard: { ud, text, len in
+ // Main thread, inside pardes_tick, with `text` borrowed for the
+ // length of the call, so the String has to be a copy.
+ guard let ud else { return }
+ let host = Unmanaged<Host>.fromOpaque(ud).takeUnretainedValue()
+ var yank = ""
+ if let text, len > 0 {
+ let bytes = UnsafeRawBufferPointer(start: UnsafeRawPointer(text), count: len)
+ yank = String(decoding: bytes, as: UTF8.self)
+ }
+ host.clipboard = yank
+ })
+
+ let rc = pardes_init(&runtime, got.cols, got.rows)
+ guard rc == 0 else {
+ throw ScriptError(message: "pardes_init returned \(rc) at \(cols)x\(rows)")
+ }
+ booted = true
+ // Only after init, exactly as AppDelegate orders it: a resize arriving
+ // before the core exists is a resize that goes nowhere, and the metrics
+ // seeded below are the core's only notion of cell size.
+ view.delegate = self
+ pardesViewDidResize(view)
+ }
+
+ func shutdown() {
+ if booted {
+ pardes_deinit()
+ booted = false
+ }
+ view?.delegate = nil
+ window?.contentView = nil
+ window = nil
+ view = nil
+ host.clipboard = nil
+ host.woke = false
+ }
+
+ // MARK: - PardesViewDelegate
+
+ func pardesViewDidResize(_ view: PardesView) {
+ let grid = view.gridSize
+ // Points, not backing-store pixels. The app passes physical pixels
+ // because the native PDF placement path measures in them; a golden must
+ // not change when the same script runs on a Retina display, and nothing
+ // here places a PDF.
+ let cellW = UInt16(max(1, min(view.cellWidth.rounded(), CGFloat(UInt16.max))))
+ let cellH = UInt16(max(1, min(view.cellHeight.rounded(), CGFloat(UInt16.max))))
+ pardes_resize(grid.cols, grid.rows, cellW, cellH)
+ pump()
+ }
+
+ func pardesViewRequestsPaste(_ view: PardesView) {
+ // The core's own yank register, never NSPasteboard: reading the system
+ // clipboard would let whatever the developer last copied into a golden.
+ let text = host.clipboard ?? ""
+ text.withCString { pardes_paste($0, text.utf8.count) }
+ pump()
+ }
+
+ // MARK: - pumping
+
+ /// Every path into the core ends here. See the ordering contract in
+ /// docs/macos.md: the input functions only advance the state machine, and
+ /// the writes, the spawns and the saves all happen in the drain.
+ ///
+ /// Deliberately does NOT take the haptic pulse the way the app's pump does.
+ /// The `haptic` command is its only reader, because a pulse consumed by a
+ /// background tick is a pulse no script could ever assert.
+ private func pump() {
+ var spins = 0
+ while pardes_tick() {
+ spins += 1
+ if spins >= maxTicksPerPump { break }
+ }
+ // The app does exactly this on every pump (AppDelegate.pump). Without
+ // it the `Font` builtin would set a path nobody ever collects, and a
+ // script asserting the new face would be asserting the old one.
+ if let view, let wanted = pardes_font_take() {
+ view.adoptFont(path: String(cString: wanted))
+ }
+ }
+
+ /// Hand the run loop a slice. Main-queue work (anything the view defers)
+ /// runs here, and so does the sleep — see the keep-alive timer in main(),
+ /// without which run(until:) returns instantly and this becomes a spin.
+ private func idle() {
+ RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
+ }
+
+ // MARK: - waiting
+
+ private func waitFor(ms: Int, needle: String) throws {
+ let deadline = Date().addingTimeInterval(Double(ms) / 1000)
+ while true {
+ pump()
+ let frame = readFrame()
+ if frame.text.contains(needle) { return }
+ if Date() >= deadline {
+ throw ScriptError(
+ message: "waited \(ms)ms for \(needle.debugDescription) and never saw it; the screen was:\n"
+ + frame.screen)
+ }
+ idle()
+ }
+ }
+
+ private func waitStable(quietMs: Int, timeoutMs: Int) {
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
+ var key = stateKey()
+ var quietSince = Date()
+ while Date() < deadline {
+ idle()
+ pump()
+ let now = stateKey()
+ // A reader thread that woke us has bytes the next tick has not fed
+ // in yet, so the screen being unchanged this instant proves nothing.
+ if now != key || host.woke {
+ host.woke = false
+ key = now
+ quietSince = Date()
+ continue
+ }
+ if Date().timeIntervalSince(quietSince) * 1000 >= Double(quietMs) { return }
+ }
+ }
+
+ /// What `stable` compares. The cursor is in it because a caret crossing an
+ /// otherwise still screen is still the app doing something.
+ private func stateKey() -> String {
+ let frame = readFrame()
+ return "\(frame.cursorX),\(frame.cursorY)\n\(frame.text)"
+ }
+
+ // MARK: - reading the grid
+
+ private func readFrame() -> Frame {
+ let count = pardes_frame()
+ let cols = Int(pardes_frame_cols())
+ let rows = Int(pardes_frame_rows())
+ // -1 means hidden. Clamped to 0 rather than printed, which is what
+ // test/web_snapshot.mjs does, so the backends' headers stay comparable.
+ let cursorX = max(0, Int(pardes_cursor_x()))
+ let cursorY = max(0, Int(pardes_cursor_y()))
+ guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else {
+ return Frame(cols: cols, rows: rows, cursorX: cursorX, cursorY: cursorY, lines: [])
+ }
+
+ var lines: [String] = []
+ lines.reserveCapacity(rows)
+ for row in 0..<rows {
+ var line = ""
+ line.reserveCapacity(cols)
+ for col in 0..<cols {
+ let cell = cells[row * cols + col]
+ // A never-painted cell is background only, and a zero-length one
+ // is the tail half of a wide glyph. Both read as a space, which
+ // is what the tty oracle's emulator hands back for them too.
+ if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 || cell.len == 0 {
+ line.append(" ")
+ continue
+ }
+ // prefix clamps, so a bogus len cannot walk off the eight bytes,
+ // and String(decoding:) substitutes U+FFFD rather than trapping
+ // on invalid UTF-8 — a capture has to be able to RECORD garbage,
+ // not die of it.
+ let text = withUnsafeBytes(of: cell.text) { raw in
+ String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
+ }
+ line.append(text.isEmpty ? " " : text)
+ }
+ lines.append(trimTrailingSpaces(line))
+ }
+ return Frame(cols: cols, rows: rows, cursorX: cursorX, cursorY: cursorY, lines: lines)
+ }
+
+ // MARK: - captures
+
+ private func snap(_ label: String) {
+ let frame = readFrame()
+ output += "== snap \(label) grid=\(frame.cols)x\(frame.rows) cursor=\(frame.cursorX),\(frame.cursorY)\n"
+ for line in frame.lines { output += "|\(stableNames(line))\n" }
+ }
+
+ /// `New` asks the shell for a temporary document and mkstemp picks six
+ /// random characters for it (src/temp_file.zig), so the pane tag holding
+ /// that name is different on every run. TMPDIR is already pinned inside the
+ /// hermetic world, which makes the DIRECTORY reproducible; this makes the
+ /// name reproducible. Masked rather than dropped, so a golden still shows
+ /// that a temp document is what got opened and where.
+ private func stableNames(_ line: String) -> String {
+ guard line.contains("pardes-") else { return line }
+ return line.replacingOccurrences(
+ of: "pardes-[A-Za-z0-9]{6}",
+ with: "pardes-XXXXXX",
+ options: .regularExpression)
+ }
+
+ /// Render the view the way AppKit would and prove the CoreText pass put
+ /// something on the screen. `snap` reads the core's cell buffer and would be
+ /// perfectly happy with a draw(_:) that returned on its first line; this is
+ /// the only command that touches the drawing code at all.
+ ///
+ /// "Not blank" is spelled "not every pixel identical" rather than "not equal
+ /// to the background colour": it needs no agreement with the view about what
+ /// that colour is or what channel order the bitmap uses, and a view that
+ /// painted one flat rect of anything is just as broken as one that painted
+ /// nothing.
+ private func draw(_ label: String) throws {
+ let view = try live()
+ let bounds = view.bounds
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
+ throw ScriptError(message: "draw \(label): the view would not make a bitmap for \(bounds.size)")
+ }
+ view.cacheDisplay(in: bounds, to: rep)
+ guard let data = rep.bitmapData, rep.pixelsWide > 0, rep.pixelsHigh > 0 else {
+ throw ScriptError(message: "draw \(label): cacheDisplay produced no pixels")
+ }
+
+ let bytesPerPixel = max(1, rep.bitsPerPixel / 8)
+ // Not `stride`, which is a standard-library function this would shadow.
+ // Rows can be padded, so only the first pixelsWide of each are pixels.
+ let rowBytes = rep.bytesPerRow
+ var uniform = true
+ scan: for y in 0..<rep.pixelsHigh {
+ let row = data + y * rowBytes
+ for x in 0..<rep.pixelsWide {
+ let pixel = row + x * bytesPerPixel
+ for byte in 0..<bytesPerPixel where pixel[byte] != data[byte] {
+ uniform = false
+ break scan
+ }
+ }
+ }
+ // The size recorded is the view's in POINTS, not the bitmap's in device
+ // pixels: the same script on a Retina machine caches a 2x rep, and a
+ // golden must not know which display the developer used.
+ let w = Int(bounds.width.rounded())
+ let h = Int(bounds.height.rounded())
+ if uniform {
+ throw ScriptError(
+ message: "draw \(label): every pixel of the \(w)x\(h) view is identical — the CoreText pass drew nothing")
+ }
+ output += "== draw \(label) \(w)x\(h) nonblank\n"
+ }
+
+ // MARK: - the script
+
+ func run(source: String) throws -> String {
+ var lineno = 0
+ for raw in source.split(separator: "\n", omittingEmptySubsequences: false) {
+ lineno += 1
+ let line = String(raw).trimmingCharacters(in: CharacterSet(charactersIn: " \t\r"))
+ if line.isEmpty || line.hasPrefix("#") { continue }
+ do {
+ try step(line)
+ } catch let error as ScriptError {
+ throw ScriptError(message: "\(scriptName):\(lineno): \(line)\n \(error.message)")
+ }
+ }
+ return output
+ }
+
+ private func step(_ line: String) throws {
+ let split = line.firstIndex(of: " ")
+ let cmd = String(split.map { line[..<$0] } ?? Substring(line))
+ // Kept verbatim for `text`, `clipboard` and the labels, where the spaces
+ // are part of the payload.
+ let rest = split.map { String(line[line.index(after: $0)...]) } ?? ""
+ let args = rest.split(separator: " ").map(String.init)
+
+ switch cmd {
+ case "start":
+ let rows = try int(args, 0, "start rows")
+ let cols = try int(args, 1, "start cols")
+ try start(rows: rows, cols: cols)
+
+ case "wait":
+ let ms = try int(args, 0, "wait timeout")
+ let needle = rest.drop(while: { $0 != " " }).dropFirst()
+ guard !needle.isEmpty else { throw ScriptError(message: "wait needs text to look for") }
+ try waitFor(ms: ms, needle: String(needle))
+
+ case "stable":
+ _ = try live()
+ let quiet = try int(args, 0, "stable quiet")
+ let timeout = try int(args, 1, "stable timeout")
+ waitStable(quietMs: quiet, timeoutMs: timeout)
+
+ case "text":
+ let view = try live()
+ guard !rest.isEmpty else { throw ScriptError(message: "text needs something to type") }
+ // Per SCALAR, not per Character: pardes_key takes one codepoint, and
+ // a grapheme cluster is not one.
+ for scalar in rest.unicodeScalars {
+ view.typeKey(scalar.value, text: String(scalar), mods: 0)
+ }
+ pump()
+
+ // A builtin command line, the way the menu and the nested-Look socket
+ // both deliver one. The only way a script can reach a builtin that has
+ // no key of its own, `Font` among them.
+ case "command":
+ _ = try live()
+ guard !rest.isEmpty else { throw ScriptError(message: "command needs a command") }
+ pardes_command(rest, rest.utf8.count)
+ pump()
+
+ // The face actually worn, by PostScript name. Asserted rather than
+ // snapshotted because a snapshot is the core's cell buffer and the
+ // core has no font: everything about which face is on screen lives on
+ // this side of the ABI, so this is the only place it can be checked.
+ case "font":
+ let view = try live()
+ guard view.faceName == rest else {
+ throw ScriptError(message: "wearing \(view.faceName.debugDescription), wanted \(rest.debugDescription)")
+ }
+
+ // Cmd+ / Cmd- / Cmd+0, minus the menu. What the grid does afterwards
+ // is the assertion: a bigger cell fits fewer columns in the same
+ // window, so a zoom that changed nothing shows up as a golden that
+ // did not move.
+ case "zoom":
+ let view = try live()
+ if rest == "reset" {
+ view.zoomReset()
+ } else {
+ guard let step = Double(rest) else {
+ throw ScriptError(message: "zoom takes a point delta or `reset`, got \(rest.debugDescription)")
+ }
+ view.zoom(by: CGFloat(step))
+ }
+ pump()
+
+ case "key":
+ let view = try live()
+ guard !args.isEmpty else { throw ScriptError(message: "key needs a name") }
+ for name in args {
+ let stroke = try keyStroke(name)
+ view.typeKey(stroke.cp, text: "", mods: stroke.mods)
+ }
+ pump()
+
+ case "mouse":
+ let view = try live()
+ let button = try mouseButton(try token(args, 0, "mouse button"))
+ let phase = try token(args, 1, "mouse phase")
+ let cell = try gridPoint(args, 2, "mouse")
+ switch phase {
+ case "press": view.press(button, at: cell)
+ case "release": view.release(button, at: cell)
+ case "drag": view.drag(button, to: cell)
+ // The view's hover entry point carries no button, because a
+ // button-less move is the only motion AppKit reports as its own
+ // event. The token is accepted and ignored so that all four phases
+ // stay spelled the same way.
+ case "motion": view.motion(to: cell)
+ default:
+ throw ScriptError(message: "mouse phase must be press, release, drag or motion, not \(phase)")
+ }
+ pump()
+
+ case "click":
+ let view = try live()
+ let button = try mouseButton(try token(args, 0, "click button"))
+ let cell = try gridPoint(args, 1, "click")
+ view.click(button, at: cell)
+ pump()
+
+ // A click driven through the NSEvent override rather than the decoded
+ // entry point, i.e. the AppKit adapter itself.
+ //
+ // This exists because a bug lived exactly here and every other test
+ // walked past it: `click` calls view.click(_:at:), which is downstream
+ // of mouseDown(with:), so an adapter that dropped every event on the
+ // floor still passed the whole suite. It dropped them because it asked
+ // a mouse event for its touch set, which raises; AppKit caught the
+ // throw inside its own dispatch and abandoned the handler, so nothing
+ // crashed and nothing logged and no click did anything.
+ //
+ // NSEvent.mouseEvent can build the real thing, so the adapter is no
+ // longer the untestable part. Anything the override does to the event
+ // that a mouse event does not support now fails here.
+ case "nsclick":
+ let view = try live()
+ guard let window = self.window else {
+ throw ScriptError(message: "nsclick before start")
+ }
+ let kind = try token(args, 0, "nsclick button")
+ let cell = try gridPoint(args, 1, "nsclick")
+ // Cell centre, in the flipped view's coordinates, back into the
+ // window's bottom-left origin that NSEvent wants.
+ let inView = CGPoint(x: CGFloat(cell.col) * view.cellWidth + view.cellWidth / 2,
+ y: CGFloat(cell.row) * view.cellHeight + view.cellHeight / 2)
+ let inWindow = view.convert(inView, to: nil)
+ let phases: [(NSEvent.EventType, NSEvent.EventType)] = switch kind {
+ case "right": [(.rightMouseDown, .rightMouseUp)]
+ case "middle", "other": [(.otherMouseDown, .otherMouseUp)]
+ default: [(.leftMouseDown, .leftMouseUp)]
+ }
+ for (down, up) in phases {
+ for type in [down, up] {
+ guard let event = NSEvent.mouseEvent(
+ with: type, location: inWindow, modifierFlags: [], timestamp: 0,
+ windowNumber: window.windowNumber, context: nil,
+ eventNumber: 0, clickCount: 1, pressure: type == down ? 1 : 0)
+ else { throw ScriptError(message: "nsclick: could not build a \(type) event") }
+ switch type {
+ case .rightMouseDown: view.rightMouseDown(with: event)
+ case .rightMouseUp: view.rightMouseUp(with: event)
+ case .otherMouseDown: view.otherMouseDown(with: event)
+ case .otherMouseUp: view.otherMouseUp(with: event)
+ case .leftMouseDown: view.mouseDown(with: event)
+ default: view.mouseUp(with: event)
+ }
+ }
+ }
+ pump()
+
+ case "fingers":
+ let view = try live()
+ // The whole two-finger-Look / three-finger-Exec feature, asserted
+ // without a trackpad: the policy is pure and lives in Trackpad, so a
+ // script names the finger count and gets the button a hand gets.
+ //
+ // The stream is the one macOS actually uses for a multi-finger
+ // click when its own secondary click is on, which is the default
+ // and which is what real hardware was observed doing for BOTH two
+ // and three fingers. Passing RIGHT here is therefore the harder
+ // case: it is the one where a view that trusted the stream would
+ // call three fingers a Look.
+ let count = try int(args, 0, "fingers count")
+ let cell = try gridPoint(args, 1, "fingers")
+ let stream = count >= 2 ? PARDES_MOUSE_RIGHT : PARDES_MOUSE_LEFT
+ view.click(Trackpad.button(stream: stream, fingers: count), at: cell)
+ pump()
+
+ case "force":
+ let view = try live()
+ let cell = try gridPoint(args, 0, "force")
+ view.click(Trackpad.forceClickButton, at: cell)
+ pump()
+
+ case "rotate":
+ let view = try live()
+ let degrees = try double(args, 0, "rotate degrees")
+ view.rotate(degrees: CGFloat(degrees))
+ pump()
+
+ case "scroll":
+ let view = try live()
+ let rows = try double(args, 0, "scroll rows")
+ let cell = try gridPoint(args, 1, "scroll")
+ // The horizontal delta is an OPTIONAL FOURTH token, appended rather
+ // than inserted, so the three-token form still reads the way the
+ // other suites' scroll commands do.
+ var cols = 0.0
+ if args.count > 3 { cols = try double(args, 3, "scroll cols") }
+ view.scroll(rows: CGFloat(rows), cols: CGFloat(cols), at: cell)
+ pump()
+
+ case "wheel":
+ let view = try live()
+ let button = try mouseButton(try token(args, 0, "wheel button"))
+ let cell = try gridPoint(args, 1, "wheel")
+ view.wheel(button, at: cell)
+ pump()
+
+ case "resize":
+ let view = try live()
+ let rows = try int(args, 0, "resize rows")
+ let cols = try int(args, 1, "resize cols")
+ guard rows > 0, cols > 0, rows <= 500, cols <= 1000 else {
+ throw ScriptError(message: "resize wants a sane grid, not \(rows)x\(cols)")
+ }
+ let size = NSSize(
+ width: CGFloat(cols) * view.cellWidth,
+ height: CGFloat(rows) * view.cellHeight)
+ window?.setContentSize(size)
+ view.setFrameSize(size)
+ // Pushed by hand rather than trusted to fall out of setFrameSize:
+ // whether AppKit calls back depends on how the view watches its own
+ // bounds, and a duplicate resize is a no-op by design (the core
+ // compares the effective viewport, not the event).
+ pardesViewDidResize(view)
+
+ case "haptic":
+ _ = try live()
+ let want = try hapticPulse(try token(args, 0, "haptic pulse"))
+ let got = pardes_take_haptic()
+ guard got == want else {
+ throw ScriptError(message: "expected the \(hapticName(want)) pulse, got \(hapticName(got))")
+ }
+
+ case "clipboard":
+ _ = try live()
+ guard let yank = host.clipboard else {
+ throw ScriptError(message: "the core never set the clipboard, wanted \(rest.debugDescription)")
+ }
+ guard yank == rest else {
+ throw ScriptError(message: "clipboard holds \(yank.debugDescription), wanted \(rest.debugDescription)")
+ }
+
+ case "snap":
+ _ = try live()
+ guard !rest.isEmpty else { throw ScriptError(message: "snap needs a label") }
+ snap(rest)
+
+ case "draw":
+ guard !rest.isEmpty else { throw ScriptError(message: "draw needs a label") }
+ try draw(rest)
+
+ default:
+ throw ScriptError(message: "unknown command \(cmd)")
+ }
+ }
+}
+
+// ---------------------------------------------------------------- vocabulary
+
+/// The keys that carry no text: the four ASCII controls and the private-use
+/// navigation block, plus test/snapshot.zig's `c-<ch>`/`a-<ch>` chord spelling.
+/// Both name sets are accepted (`esc`/`escape`, `bs`/`backspace`,
+/// `pgup`/`pageup`) so a script reads the same in either suite.
+private func keyStroke(_ name: String) throws -> (cp: UInt32, mods: UInt32) {
+ switch name {
+ case "enter", "ret": return (UInt32(PARDES_KEY_ENTER), 0)
+ case "escape", "esc": return (UInt32(PARDES_KEY_ESCAPE), 0)
+ case "tab": return (UInt32(PARDES_KEY_TAB), 0)
+ case "backspace", "bs": return (UInt32(PARDES_KEY_BACKSPACE), 0)
+ case "up": return (UInt32(PARDES_KEY_UP), 0)
+ case "down": return (UInt32(PARDES_KEY_DOWN), 0)
+ case "left": return (UInt32(PARDES_KEY_LEFT), 0)
+ case "right": return (UInt32(PARDES_KEY_RIGHT), 0)
+ case "home": return (UInt32(PARDES_KEY_HOME), 0)
+ case "end": return (UInt32(PARDES_KEY_END), 0)
+ case "pageup", "pgup": return (UInt32(PARDES_KEY_PAGE_UP), 0)
+ case "pagedown", "pgdn": return (UInt32(PARDES_KEY_PAGE_DOWN), 0)
+ case "delete", "del": return (UInt32(PARDES_KEY_DELETE), 0)
+ default: break
+ }
+ // A chord is the only way to reach the core's Ctrl bindings, and one of them
+ // — Ctrl-b — is how a terminal pane leaves raw tty mode, which every script
+ // that clicks on a word first has to do. The text is deliberately empty: the
+ // core reads the codepoint and the modifier, never a control character.
+ let scalars = Array(name.unicodeScalars)
+ if scalars.count == 3, scalars[1] == "-" {
+ switch scalars[0] {
+ case "c": return (scalars[2].value, UInt32(PARDES_MOD_CTRL))
+ case "a": return (scalars[2].value, UInt32(PARDES_MOD_ALT))
+ default: break
+ }
+ }
+ throw ScriptError(message: "unknown key \(name)")
+}
+
+private func mouseButton(_ name: String) throws -> pardes_mouse_button_e {
+ switch name {
+ // The honest token for a button-less move. The view's motion(to:) carries
+ // no button at all, so this only ever reaches `mouse none motion`.
+ case "none": return PARDES_MOUSE_NONE
+ case "left": return PARDES_MOUSE_LEFT
+ case "middle": return PARDES_MOUSE_MIDDLE
+ case "right": return PARDES_MOUSE_RIGHT
+ case "wheelup": return PARDES_MOUSE_WHEEL_UP
+ case "wheeldown": return PARDES_MOUSE_WHEEL_DOWN
+ case "wheelleft": return PARDES_MOUSE_WHEEL_LEFT
+ case "wheelright": return PARDES_MOUSE_WHEEL_RIGHT
+ default: throw ScriptError(message: "unknown mouse button \(name)")
+ }
+}
+
+private func hapticPulse(_ name: String) throws -> pardes_haptic_e {
+ switch name {
+ case "none": return PARDES_HAPTIC_NONE
+ case "exec": return PARDES_HAPTIC_EXEC
+ case "look": return PARDES_HAPTIC_LOOK
+ default: throw ScriptError(message: "unknown haptic pulse \(name)")
+ }
+}
+
+private func hapticName(_ pulse: pardes_haptic_e) -> String {
+ if pulse == PARDES_HAPTIC_EXEC { return "exec" }
+ if pulse == PARDES_HAPTIC_LOOK { return "look" }
+ return "none"
+}
+
+private func token(_ args: [String], _ index: Int, _ what: String) throws -> String {
+ guard index < args.count else { throw ScriptError(message: "missing \(what)") }
+ return args[index]
+}
+
+private func int(_ args: [String], _ index: Int, _ what: String) throws -> Int {
+ let raw = try token(args, index, what)
+ guard let value = Int(raw) else {
+ throw ScriptError(message: "\(what) is not a number: \(raw)")
+ }
+ return value
+}
+
+private func uint16(_ args: [String], _ index: Int, _ what: String) throws -> UInt16 {
+ let raw = try token(args, index, what)
+ guard let value = UInt16(raw) else {
+ throw ScriptError(message: "\(what) is not a cell coordinate: \(raw)")
+ }
+ return value
+}
+
+/// A `<col> <row>` pair, always in that order and always 0-based — the cell
+/// coordinates pardes_mouse takes. The tty suite's SGR commands are 1-based and
+/// so read one higher for the same cell.
+private func gridPoint(_ args: [String], _ index: Int, _ what: String) throws -> GridPoint {
+ let col = try uint16(args, index, "\(what) col")
+ let row = try uint16(args, index + 1, "\(what) row")
+ return GridPoint(col: col, row: row)
+}
+
+private func double(_ args: [String], _ index: Int, _ what: String) throws -> Double {
+ let raw = try token(args, index, what)
+ guard let value = Double(raw) else {
+ throw ScriptError(message: "\(what) is not a number: \(raw)")
+ }
+ return value
+}
+
+// ----------------------------------------------------------- hermetic world
+
+/// Rebuild the world one script sees, before pardes_init reads any of it. This
+/// is test/snapshot.zig's per-script setup spelled in Swift: same fake HOME,
+/// same pinned bash prompt, same config, same frozen clock.
+private func buildWorld(stem: String) throws -> String {
+ let fm = FileManager.default
+ let base = "\(workBase)/\(stem)"
+ // Recreated, not reused: a file left by a previous run is a file the pane
+ // tag or an `ls` would show.
+ try? fm.removeItem(atPath: base)
+ let home = "\(base)/home"
+ let work = "\(base)/cwd"
+ let configHome = "\(home)/.config"
+ // `New` creates its document under TMPDIR (src/temp_file.zig). Left alone
+ // that is the per-user /var/folders/... path launchd hands out, which is
+ // different on every machine and lands verbatim in a pane tag — a golden
+ // that could only ever pass for whoever generated it.
+ let tmp = "\(base)/tmp"
+ for dir in [base, home, work, configHome, tmp] {
+ try fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ }
+
+ // The shells are started with `--rcfile /tmp/pardes-osc133.bash`, which
+ // sources $HOME/.bashrc (src/shell_bin.zig) — so this is what pins the
+ // prompt to `$ ` and keeps the developer's history out of the capture.
+ try "PS1='$ '\nHISTFILE=\n".write(toFile: "\(home)/.bashrc", atomically: true, encoding: .utf8)
+ // ...and the config is not empty, because the DEFAULT shell is fish, whose
+ // prompt carries a hostname and whose greeting carries a version. A golden
+ // taken against that is a golden for one machine.
+ try "Shell bash\n".write(toFile: "\(configHome)/pardes", atomically: true, encoding: .utf8)
+
+ // The return value is the failure of a memory allocation and nothing else;
+ // there is no recovery worth writing, and a golden taken in a world that
+ // half-built would be nonsense anyway.
+ _ = setenv("HOME", home, 1)
+ // Set even though it now points inside the fake HOME: a developer who
+ // exports XDG_CONFIG_HOME somewhere else would otherwise boot the core with
+ // their own startup builtins.
+ _ = setenv("XDG_CONFIG_HOME", configHome, 1)
+ _ = setenv("TERM", "xterm-256color", 1)
+ _ = setenv("LC_ALL", "C", 1)
+ _ = setenv("TMPDIR", tmp, 1)
+ // A wall clock cannot live in a golden. Everything that would print one
+ // prints fixed characters instead when this is set.
+ _ = setenv("PARDES_NOTIME", "1", 1)
+ guard fm.changeCurrentDirectoryPath(work) else {
+ throw ScriptError(message: "could not chdir into \(work)")
+ }
+ return work
+}
+
+// ---------------------------------------------------------------- the runner
+
+private func firstDiff(_ golden: String, _ actual: String) -> String {
+ let g = golden.split(separator: "\n", omittingEmptySubsequences: false)
+ let a = actual.split(separator: "\n", omittingEmptySubsequences: false)
+ for n in 0..<max(g.count, a.count) {
+ let left = n < g.count ? String(g[n]) : "<eof>"
+ let right = n < a.count ? String(a[n]) : "<eof>"
+ if left == right { continue }
+ return " first diff at golden line \(n + 1):\n -\(left)\n +\(right)\n"
+ }
+ return ""
+}
+
+private func absolute(_ path: String) -> String {
+ if path.hasPrefix("/") { return path }
+ return FileManager.default.currentDirectoryPath + "/" + path
+}
+
+private func snapScripts(in dir: String) -> [String] {
+ let entries = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []
+ return entries.filter { $0.hasSuffix(".snap") }.sorted().map { "\(dir)/\($0)" }
+}
+
+/// One script end to end. Returns true if it passed.
+private func runOne(scriptPath: String, update: Bool) -> Bool {
+ let name = (scriptPath as NSString).lastPathComponent
+ let stem = (name as NSString).deletingPathExtension
+ let goldenPath = String(scriptPath.dropLast(".snap".count)) + ".golden"
+ let actualPath = String(scriptPath.dropLast(".snap".count)) + ".actual"
+
+ let driver = Driver(scriptName: name)
+ // pardes_deinit is mandatory, not tidiness: the ABI is a singleton, so a
+ // core left standing makes the NEXT script's pardes_init return 1.
+ defer { driver.shutdown() }
+
+ let started = Date()
+ let out: String
+ do {
+ let source = try String(contentsOfFile: scriptPath, encoding: .utf8)
+ _ = try buildWorld(stem: stem)
+ out = try driver.run(source: source)
+ } catch let error as ScriptError {
+ print("FAIL \(stem): \(error.message)")
+ return false
+ } catch {
+ print("FAIL \(stem): \(error)")
+ return false
+ }
+ let took = Int(Date().timeIntervalSince(started) * 1000)
+
+ if update {
+ do {
+ try out.write(toFile: goldenPath, atomically: true, encoding: .utf8)
+ print("UPDATED \(goldenPath) (\(out.utf8.count) bytes)")
+ return true
+ } catch {
+ print("FAIL \(stem): could not write \(goldenPath): \(error)")
+ return false
+ }
+ }
+
+ guard let golden = try? String(contentsOfFile: goldenPath, encoding: .utf8) else {
+ print("FAIL \(stem): no golden (run with -- --update)")
+ return false
+ }
+ if golden == out {
+ print("PASS \(stem) (\(took)ms)")
+ return true
+ }
+ try? out.write(toFile: actualPath, atomically: true, encoding: .utf8)
+ print("FAIL \(stem): differs from golden (actual written to \(actualPath))")
+ print(firstDiff(golden, out), terminator: "")
+ return false
+}
+
+@main
+struct MacosE2E {
+ static func main() {
+ // Before any NSWindow exists: AppKit will not make one without an
+ // application object, and the policy has to be in place before the first
+ // window so that nothing ever asks the window server for focus. This has
+ // to be able to run inside a build, over ssh, beside a developer who is
+ // typing in another app.
+ //
+ // UNVERIFIED: .prohibited is documented as "may not create windows", and
+ // an offscreen window is nevertheless the standard way to run AppKit
+ // headless. If `draw` ever reports a blank view on a machine where the
+ // app itself renders, .accessory is the one-word fallback — it also
+ // keeps the process out of the Dock, it merely permits activation.
+ NSApplication.shared.setActivationPolicy(.prohibited)
+
+ // A run loop with no sources attached returns from run(until:)
+ // immediately, which would turn every poll into a busy spin. This timer
+ // never does anything; it exists so the deadline is honoured as a sleep.
+ let keepAlive = Timer(timeInterval: 3600, repeats: true) { _ in }
+ RunLoop.current.add(keepAlive, forMode: .default)
+
+ var update = false
+ var positional: [String] = []
+ for arg in CommandLine.arguments.dropFirst() {
+ if arg == "--update" { update = true } else { positional.append(arg) }
+ }
+
+ // Every path is made absolute up front, because each script chdirs into
+ // its own hermetic cwd before it runs and the goldens live back here.
+ var scripts: [String] = []
+ for arg in positional.isEmpty ? [defaultScriptDir] : positional {
+ let path = absolute(arg)
+ var isDir: ObjCBool = false
+ if FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue {
+ scripts.append(contentsOf: snapScripts(in: path))
+ } else {
+ scripts.append(path)
+ }
+ }
+ if scripts.isEmpty {
+ print("no .snap scripts found")
+ exit(1)
+ }
+
+ var failed = 0
+ // Serial, unlike the tty suite's fork-per-script fan-out: the ABI is a
+ // singleton and the hermetic world is process-global environment plus a
+ // chdir, so two scripts at once in one process would be two scripts in
+ // each other's world.
+ for script in scripts {
+ if !runOne(scriptPath: script, update: update) { failed += 1 }
+ }
+ keepAlive.invalidate()
+ if failed > 0 {
+ print("\(failed)/\(scripts.count) macos e2e scripts FAILED")
+ exit(1)
+ }
+ print("all \(scripts.count) macos e2e scripts ok")
+ exit(0)
+ }
+}