diff options
Diffstat (limited to 'test/macos_e2e.swift')
| -rw-r--r-- | test/macos_e2e.swift | 983 |
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) + } +} |
