diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-11 15:58:58 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 16:26:20 -0300 |
| commit | 4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch) | |
| tree | face852acae5bc347e6bab2bb5cede501e0ce1d3 /test | |
| parent | dedfdea43f0d6c7151c541284c81027969d89032 (diff) | |
| parent | 89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff) | |
| download | pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip | |
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump.
No textual conflicts, and two things the merge needed:
- nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it
`void` (glibc hides it behind a versioned symbol std cannot name), so the tty
build stopped at 'type void not a function'. statNoFollow keeps fstatat on
darwin and asks statx on linux for the same three fields, which is what this
file did before the branch generalized it to both platforms.
- .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so.
linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig
also type-checks for aarch64-macos.
Diffstat (limited to 'test')
| -rw-r--r-- | test/e2e_harness.zig | 10 | ||||
| -rw-r--r-- | test/macos-snapshots/boot.golden | 58 | ||||
| -rw-r--r-- | test/macos-snapshots/boot.snap | 23 | ||||
| -rw-r--r-- | test/macos-snapshots/cwd.golden | 100 | ||||
| -rw-r--r-- | test/macos-snapshots/cwd.snap | 32 | ||||
| -rw-r--r-- | test/macos-snapshots/drop.golden | 94 | ||||
| -rw-r--r-- | test/macos-snapshots/drop.snap | 37 | ||||
| -rw-r--r-- | test/macos-snapshots/font.golden | 176 | ||||
| -rw-r--r-- | test/macos-snapshots/font.snap | 49 | ||||
| -rw-r--r-- | test/macos-snapshots/keys.golden | 325 | ||||
| -rw-r--r-- | test/macos-snapshots/keys.snap | 59 | ||||
| -rw-r--r-- | test/macos-snapshots/rotate.golden | 280 | ||||
| -rw-r--r-- | test/macos-snapshots/rotate.snap | 107 | ||||
| -rw-r--r-- | test/macos-snapshots/trackpad.golden | 311 | ||||
| -rw-r--r-- | test/macos-snapshots/trackpad.snap | 91 | ||||
| -rw-r--r-- | test/macos_e2e.swift | 1016 | ||||
| -rw-r--r-- | test/snapshots/builtins.golden | 192 | ||||
| -rw-r--r-- | test/snapshots/builtins.snap | 10 | ||||
| -rw-r--r-- | test/snapshots/leader.golden | 40 | ||||
| -rw-r--r-- | test/snapshots/ttyhelp.golden | 72 |
20 files changed, 2926 insertions, 156 deletions
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index 2e87d362..4767871f 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -183,8 +183,16 @@ pub const Harness = struct { /// input just sent: without it, an app still asleep in its event loop looks /// exactly like an app that has finished. See snapshot.zig's waitStable. pub fn pending(self: *Harness) usize { + // Darwin files FIONREAD under the socket ioctls rather than the + // termios group std.posix.T exposes, and encodes it differently + // besides — _IOR('f', 127, int) against linux's flat constant. + // Neither is derivable from the other, so both are named. + const FIONREAD: c_int = switch (@import("builtin").os.tag) { + .linux => posix.T.FIONREAD, + else => 0x4004667f, + }; var n: c_int = 0; - if (posix.system.ioctl(self.master, posix.T.FIONREAD, @intFromPtr(&n)) != 0) return 0; + if (posix.system.ioctl(self.master, FIONREAD, @intFromPtr(&n)) != 0) return 0; return if (n > 0) @intCast(n) else 0; } diff --git a/test/macos-snapshots/boot.golden b/test/macos-snapshots/boot.golden new file mode 100644 index 00000000..1c971b3f --- /dev/null +++ b/test/macos-snapshots/boot.golden @@ -0,0 +1,58 @@ +== snap boot grid=80x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/boot/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== draw boot 680x396 nonblank +== snap resized grid=100x30 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/boot/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== draw resized 850x495 nonblank diff --git a/test/macos-snapshots/boot.snap b/test/macos-snapshots/boot.snap new file mode 100644 index 00000000..e3d4ba14 --- /dev/null +++ b/test/macos-snapshots/boot.snap @@ -0,0 +1,23 @@ +# The whole boot path in one script: pardes_init at the grid the offscreen +# window actually measured, the first shell reaching its prompt, and the +# CoreText pass painting. `draw` is the only command in the suite that touches +# the drawing code at all — everything else reads the core's cell buffer, which +# a draw(_:) that returned on its first line would leave perfectly intact. +# +# The macOS backend takes no argv, so it always boots the way a bare +# `start 30 140` does in test/snapshots: one terminal pane in raw tty mode, +# prompt visible. `$ ` is the prompt the hermetic .bashrc pins, and no chrome +# row carries a `$`, so waiting on one is waiting on the shell. +start 24 80 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +snap boot +draw boot +# The window changing size is the other half of the boot contract: the core +# reflows, the view re-measures, and the second `draw` proves the new size is +# the size that actually got painted. +resize 30 100 +stable 700 20000 +snap resized +draw resized diff --git a/test/macos-snapshots/cwd.golden b/test/macos-snapshots/cwd.golden new file mode 100644 index 00000000..748d523d --- /dev/null +++ b/test/macos-snapshots/cwd.golden @@ -0,0 +1,100 @@ +== snap spawned grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/cwd/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved grid=100x24 cursor=4,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp New Del +| $ cd /tmp +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved-again grid=100x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ / New Del +| $ cd /tmp +| $ cd / +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap idle grid=100x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ / New Del +| $ cd /tmp +| $ cd / +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/cwd.snap b/test/macos-snapshots/cwd.snap new file mode 100644 index 00000000..87f6f9ef --- /dev/null +++ b/test/macos-snapshots/cwd.snap @@ -0,0 +1,32 @@ +# The pane tag has to follow the shell around, because a relative `Look` is +# resolved against it: with a stale cwd, clicking `README.md` after a `cd` +# looks for it in the directory the pane was SPAWNED in and finds nothing. +# +# On linux the host re-reads /proc/<pid>/cwd every frame. macOS asks libproc, +# and asks it only for a pane that just produced output — a `cd` is a command +# and a shell that ran a command prints its next prompt, so nothing else can +# have moved one. This script is what proves that gating is not too clever: +# the tag must be right AFTER the cd and must stay right when nothing happens. +start 24 100 +wait 8000 $ +stable 700 20000 +# The hermetic world puts the shell in <base>/work, so the boot tag names it +# and no absolute path from this machine can leak into the golden. +snap spawned +# `cd` writes nothing but the next prompt: the one and only signal the lazy +# refresh gets. Two of them, because the second has to survive the first +# having already cleared the flag. +text cd /tmp +key enter +wait 8000 $ +stable 700 20000 +snap moved +text cd / +key enter +wait 8000 $ +stable 700 20000 +snap moved-again +# ...and an idle pass must not lose it again: the refresh clears its own flag, +# so a tick with no output must leave the tag exactly where it was. +stable 700 20000 +snap idle diff --git a/test/macos-snapshots/drop.golden b/test/macos-snapshots/drop.golden new file mode 100644 index 00000000..b4143983 --- /dev/null +++ b/test/macos-snapshots/drop.golden @@ -0,0 +1,94 @@ +== snap booted grid=100x30 cursor=4,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/drop/cwd New Del +| $ printf 'first file\n' > one.txt; printf 'second file\n' > two.txt +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap dropped_on_shell grid=100x30 cursor=7,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/drop/cwd/one.txt S$ /private/tmp/pardes-macos-e2e/drop/cwd New Del +| 1 first file $ printf 'first file\n' > one.txt; printf 'secon +| 2 d file\n' > two.txt +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap dropped_on_document grid=100x30 cursor=7,17 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/drop/cwd/one.txt S$ /private/tmp/pardes-macos-e2e/drop/cwd New Del +| 1 first file $ printf 'first file\n' > one.txt; printf 'secon +| 2 d file\n' > two.txt +| $ +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/drop/cwd/two.txt S +| 1 second file +| 2 +| +| +| +| +| +| +| +| +| +| +| +== draw drop 850x495 nonblank diff --git a/test/macos-snapshots/drop.snap b/test/macos-snapshots/drop.snap new file mode 100644 index 00000000..f427fe2a --- /dev/null +++ b/test/macos-snapshots/drop.snap @@ -0,0 +1,37 @@ +# A file dropped ON the grid opens beside the pane it was dropped on. +# +# Finder and the Dock already reach the app through application(_:open:), but +# that path cannot say WHERE — it opens next to whichever pane happened to have +# focus. A drop knows where the hand was, and in acme that is the whole +# difference: `Look` places the document relative to the pane it runs in. +# +# So a drop is defined as a CLICK followed by `Look`, and nothing more. The +# click focuses the pane under the pointer exactly as a left click there would, +# then the ordinary builtin runs in it. There is no drop concept in the core +# and none was added — everything here the hand could have done itself. +# +# `drop` drives PardesView.drop, one call below performDragOperation, because +# NSDraggingInfo is a protocol with no public conformer: a test that had to +# build one would be testing its own stub. See the trackpad scripts for the +# same reasoning about NSTouch and pressure stages. +start 30 100 +wait 8000 $ +stable 700 20000 +text printf 'first file\n' > one.txt; printf 'second file\n' > two.txt +key enter +wait 8000 $ +stable 700 20000 +snap booted +# Dropped on the shell: one.txt opens as a document beside it. Absolute, +# because the core resolves a relative Look against the PANE's directory — +# the same absolute path the AppKit side builds out of an NSURL. +drop /private/tmp/pardes-macos-e2e/drop/cwd/one.txt 10 6 +stable 700 15000 +snap dropped_on_shell +# ...and now the placement is the assertion. Dropping the SECOND file inside +# the pane the first one opened puts it next to that pane, not next to the +# shell — which is only true because the click went where the pointer was. +drop /private/tmp/pardes-macos-e2e/drop/cwd/two.txt 10 20 +stable 700 15000 +snap dropped_on_document +draw drop diff --git a/test/macos-snapshots/font.golden b/test/macos-snapshots/font.golden new file mode 100644 index 00000000..759e7738 --- /dev/null +++ b/test/macos-snapshots/font.golden @@ -0,0 +1,176 @@ +== snap boot grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap menlo grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap unchanged grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap bigger grid=77x18 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap reset grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap floor grid=242x56 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/font.snap b/test/macos-snapshots/font.snap new file mode 100644 index 00000000..71049b55 --- /dev/null +++ b/test/macos-snapshots/font.snap @@ -0,0 +1,49 @@ +# The font picker and the zoom, which are the two things this shell decides +# entirely on its own. Neither is visible in a cell buffer — the core has no +# font and no idea what a point is — so what a snapshot CAN see is the second +# effect of both: a cell that changed size fits a different number of columns +# into the same window, and the core reflows to it. +# +# `font` asserts the face by PostScript name, which is the only direct look at +# the thing under test. +start 24 100 +wait 8000 $ +stable 700 20000 +snap boot + +# Menlo ships with every macOS and ships inside a .ttc, so this is also the +# end-to-end proof of the collection branch in src/fonts.zig: the core walks +# the font directories, matches the name, hands over a path, and the host has +# to pull face 0 out of a file holding four. +command Font Menlo +stable 700 20000 +font Menlo-Regular +snap menlo + +# A name that resolves to nothing must leave the screen alone rather than +# fall back to something. `Font` finds no file, sets no path, and the host is +# never asked to do anything at all. +command Font NoSuchFaceExistsHere +stable 700 20000 +font Menlo-Regular +snap unchanged + +# Zoom is a pure host property: no builtin, no core round trip, just a bigger +# cell and the reflow that follows it. Four points is enough to move the grid +# at any starting size. +zoom 4 +stable 700 20000 +font Menlo-Regular +snap bigger + +# ...and back, which must land on exactly the grid it started from — the whole +# point of "actual size" being a fixed number rather than an undo stack. +zoom reset +stable 700 20000 +snap reset + +# Small enough to be worth checking the clamp does not run away: the range +# floor is 6pt, so this asks for far past it and must simply stop. +zoom -40 +stable 700 20000 +snap floor diff --git a/test/macos-snapshots/keys.golden b/test/macos-snapshots/keys.golden new file mode 100644 index 00000000..cff9484e --- /dev/null +++ b/test/macos-snapshots/keys.golden @@ -0,0 +1,325 @@ +== snap typed grid=80x24 cursor=16,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap ran grid=80x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap fixed grid=80x24 cursor=16,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ echo bra''vo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap second grid=80x24 cursor=4,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ echo bra''vo +| bravo +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap normal grid=80x24 cursor=4,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved grid=80x24 cursor=7,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap home grid=80x24 cursor=2,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap end grid=80x24 cursor=6,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap pagedown grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap pageup grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap deleted grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap escaped grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap tabbed grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| bash: bravo: command not found +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/keys.snap b/test/macos-snapshots/keys.snap new file mode 100644 index 00000000..b81b88b7 --- /dev/null +++ b/test/macos-snapshots/keys.snap @@ -0,0 +1,59 @@ +# Both halves of pardes_key's vocabulary through PardesView.typeKey: the four +# ASCII controls, the private-use navigation block, and a Ctrl chord. Markers +# are split with '' so the echoed command line can never be the thing a `wait` +# matched — the output has to be what arrived. +start 24 80 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +# raw tty mode: characters and Enter travel to the shell through the pty +text echo al''pha +stable 400 5000 +snap typed +key enter +wait 8000 alpha +stable 700 10000 +snap ran +# backspace before Enter — readline owns the line while the pane is raw +text echo bra''vv +key backspace +text o +stable 400 5000 +snap fixed +key enter +wait 8000 bravo +stable 700 10000 +snap second +# Ctrl-b leaves raw tty mode. From here the same keys are the editor's, which +# is the half of the keyboard the pty never sees. +key c-b +stable 600 8000 +snap normal +key up up left +stable 400 5000 +snap moved +key home +stable 400 5000 +snap home +key end +stable 400 5000 +snap end +key pagedown +stable 400 5000 +snap pagedown +key pageup +stable 400 5000 +snap pageup +key delete +stable 400 5000 +snap deleted +# Escape in normal mode is Last, and with one pane there is nowhere to go — +# which is exactly the no-op worth pinning: a regression here starts jumping. +key escape +stable 400 5000 +snap escaped +# Tab falls through to Exec on the word under the cursor; parked where there is +# no word, this pins the empty case rather than spawning anything. +key tab +stable 700 10000 +snap tabbed diff --git a/test/macos-snapshots/rotate.golden b/test/macos-snapshots/rotate.golden new file mode 100644 index 00000000..f0cc1eac --- /dev/null +++ b/test/macos-snapshots/rotate.golden @@ -0,0 +1,280 @@ +== snap results grid=100x30 cursor=4,25 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| zz +| x MARK 14 +| zz +| x MARK 15 +| zz +| x MARK 16 +| zz +| x MARK 17 +| zz +| x MARK 18 +| zz +| x MARK 19 +| zz +| x MARK 20 +| zz +| x MARK 21 +| zz +| x MARK 22 +| zz +| x MARK 23 +| zz +| x MARK 24 +| zz +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap forward grid=100x30 cursor=7,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap forward2 grid=100x30 cursor=7,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap back grid=100x30 cursor=7,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap forward_by_key grid=100x30 cursor=7,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap placed grid=100x30 cursor=7,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap placed_released grid=100x30 cursor=7,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:3:3-6 x MARK 01 +| 2 @p0:5:3-6 x MARK 02 +| 3 @p0:7:3-6 x MARK 03 +== snap thrown grid=100x30 cursor=7,16 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| +| x MARK 01 +| zz +| x MARK 02 +| zz +| x MARK 03 +| zz +| x MARK 04 +| zz +| x MARK 05 +| zz +| x MARK 06 +| zz +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 5 @p0:11:3-6 x MARK 05 +| 6 @p0:13:3-6 x MARK 06 +| 7 @p0:15:3-6 x MARK 07 +== snap thrown_coasted grid=100x30 cursor=7,22 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| x MARK 07 +| zz +| x MARK 08 +| zz +| x MARK 09 +| zz +| x MARK 10 +| zz +| x MARK 11 +| zz +| x MARK 12 +| zz +| x MARK 13 +| zz +| x MARK 14 +| zz +| x MARK 15 +| zz +| x MARK 16 +| zz +| x MARK 17 +| zz +| x MARK 18 +| zz +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 15 @p0:31:3-6 x MARK 15 +| 16 @p0:33:3-6 x MARK 16 +| 17 @p0:35:3-6 x MARK 17 +== draw rotate 850x495 nonblank diff --git a/test/macos-snapshots/rotate.snap b/test/macos-snapshots/rotate.snap new file mode 100644 index 00000000..1f2e4667 --- /dev/null +++ b/test/macos-snapshots/rotate.snap @@ -0,0 +1,107 @@ +# pardes_rotate spends a two-finger twist as the search-step keys. +# +# AppKit reports rotation counterclockwise-positive and the core reads clockwise +# as `n`, so a NEGATIVE delta is the FORWARD step. That inversion is exactly the +# kind of thing a unit test on the accumulator cannot catch and a golden can: +# what the screen does is the assertion. +# +# One notch is 10 degrees (rotation_notch_degrees in src/macos.zig) and the +# remainder is banked, which makes the pairs below exact rather than +# approximate: -12 spends one notch and banks -2. +start 30 100 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +# Twenty-four hits, not three. A coast is worth asserting only on a list long +# enough to coast ALONG: `n` at the last match has nowhere to go, so on a +# three-hit list the hardest possible flick and no flick at all produce the +# same screen — which is a golden that would have passed before momentum +# existed. printf reuses its format once per argument. +text printf 'x MA''RK %s\nzz\n' 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 +key enter +wait 10000 MARK 24 +stable 700 10000 +key c-b +stable 600 8000 +# `/` types the pattern into the tag; Enter searches the pane's scrollback and +# writes the hits into a +Search buffer, which is what n/N walk. +text / +stable 400 5000 +text MARK +stable 400 5000 +key enter +wait 10000 @p0: +stable 700 15000 +snap results +# A gesture beginning re-zeros the dial, so travel left over from an earlier +# twist cannot make the first degree of this one jump a match. +rotate 0 +# Clockwise: one notch of `n`, forward onto the first hit and selecting it. +rotate -12 +stable 700 15000 +snap forward +# ...and again, onto the second. Two steps out rather than one, because the +# first hit is where the list already starts: stepping BACK from it has nowhere +# to go, and a golden that cannot move is a golden that proves nothing about +# which way the dial turns. +rotate -12 +stable 700 15000 +snap forward2 +# A new gesture, because the dial banks its remainder exactly like the scroll +# accumulator does: two 12-degree notches leave -4 behind, so reversing INSIDE +# the same twist would have to spend that first and 12 back would move nothing. +# That hysteresis is wanted — it is what stops a thumb resettling on a notch +# boundary from flip-flopping between two matches — and lifting the fingers is +# how a hand clears it. +rotate 0 +# Counterclockwise: `N`. This must land back exactly on `forward`. +rotate 12 +stable 700 15000 +snap back +# And the other half of the contract: a twist IS the keystroke, so typing the +# key the dial claims to send must go the same way the dial went. `n` here must +# reproduce `forward2` — if the dial were sending something else, or the sign +# were inverted, these two would part company. +text n +stable 700 15000 +snap forward_by_key +# ---- momentum ---- +# +# The dial coasts in proportion to how fast it was RELEASED, ramping up from +# zero at the floor rather than switching on at it. Both halves of that are +# here, and the gap argument is what makes them different: it is a real sleep +# before the event, so libpardes measures a real speed off its clock. +# +# PLACED. Four degrees every 100 ms is 40 deg/s, under the 70 deg/s floor, so +# lifting the fingers changes nothing at all — the twist itself has moved one +# notch and that is where it stops. This is the case that has to hold: a slow +# deliberate turn that kept sliding afterwards would be unusable. +rotate 0 +rotate -4 100 +rotate -4 100 +rotate -4 100 +stable 700 15000 +snap placed +rotate_end +stable 700 15000 +# Byte-identical to `placed`, which is the whole assertion. +snap placed_released +# THROWN. The same twist delivered in 5 ms slices is hundreds of degrees a +# second, so the release keeps the list walking after the fingers are gone. +# +# `snap` and then `rotate_end` with NO `stable` between them, which is not +# impatience: a release is only a throw if it arrives while the hand is still +# moving, and a 700 ms wait here would be a hand that stopped — which is +# exactly what the PLACED case above already proves. The snapshot is a frame +# read, far inside the 90 ms that separates the two. +rotate 0 +rotate -12 5 +rotate -12 5 +rotate -12 5 +snap thrown +rotate_end +stable 700 15000 +# ...and this one must NOT match `thrown`: the fling moved on its own, after +# the gesture was over. That difference IS the feature. +snap thrown_coasted +draw rotate diff --git a/test/macos-snapshots/trackpad.golden b/test/macos-snapshots/trackpad.golden new file mode 100644 index 00000000..5a82ea85 --- /dev/null +++ b/test/macos-snapshots/trackpad.golden @@ -0,0 +1,311 @@ +== snap ready grid=100x30 cursor=4,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap adapter grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap look grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap dragged grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap hover grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap scrolled grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap notched grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap exec grid=100x30 cursor=7,10 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX Save New Del +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap force grid=100x30 cursor=54,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New D /private/tmp/pardes-macos-e2e/trackpad/cwd New D +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Sear +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap select grid=100x30 cursor=4,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New D /private/tmp/pardes-macos-e2e/trackpad/cwd New D +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Sear +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== draw trackpad 850x495 nonblank diff --git a/test/macos-snapshots/trackpad.snap b/test/macos-snapshots/trackpad.snap new file mode 100644 index 00000000..05f46e1f --- /dev/null +++ b/test/macos-snapshots/trackpad.snap @@ -0,0 +1,91 @@ +# The trackpad features, asserted without a trackpad, plus the rest of the +# pointer vocabulary. +# +# Trackpad.button(fingers:) is pure policy with no NSEvent in it, so a script +# can name how many fingers were resting and get back the button a hand would +# have produced: two is acme's button 3 (Look), three or more is button 2 +# (Exec), and a deep press is button 2 by another route. pardes_take_haptic is +# the other half — it is how the pulse the core armed is read back on a machine +# with nothing to feel it with. +# +# Coordinates are 0-based cells, which is what pardes_mouse takes. The tty +# suite's press/release are 1-based SGR and so read one higher. +start 30 100 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +# something to look at; split so the echoed command line is not the match +text printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' +key enter +wait 10000 MARK c +stable 700 10000 +# A raw pane hands clicks to the program on the other end. Normal mode is where +# the three buttons mean Select, Exec and Look. +key c-b +stable 600 8000 +snap ready +# THE ADAPTER ITSELF, before any of the decoded paths below. `nsclick` builds a +# real NSEvent and hands it to the view's own rightMouseDown, so this is the one +# command that exercises the code between AppKit and the entry points. It is +# here because an adapter that silently dropped every mouse event once passed +# this entire suite: everything else calls the entry points directly. +nsclick right 4 3 +wait 10000 @p0: +stable 700 15000 +haptic look +snap adapter +# Back to a clean slate for the finger-count cases below. +haptic none +# TWO FINGERS RESTING = button 3 = Look. MARK names no file, so looking at it +# searches, and the hits land in a +Search buffer split under the shell. +fingers 2 4 3 +wait 10000 @p0: +stable 700 15000 +haptic look +snap look +# ...and taking the pulse cleared it +haptic none +# The three press phases one at a time rather than through `click`, which is +# what proves press, drag and release are separate entry points and not a lump. +mouse left press 2 3 +mouse left drag 9 3 +mouse left release 9 3 +stable 700 10000 +snap dragged +# ponytail: `y` here would yank that selection into the core's register and +# fire set_clipboard, which is what the harness's `clipboard <text>` asserts. +# It is not scripted because the expected text has to be READ off this golden +# first — a guessed span would hand the next person a failing script instead of +# a capture. Add the two lines once `snap dragged` shows what got selected. +# A button-less hover: the core tracks it for the link and definition underline. +mouse none motion 4 5 +stable 400 5000 +snap hover +# A trackpad's precise scroll is fractional rows the core accumulates into whole +# wheel presses; a real mouse notch skips the smoothing and arrives as an +# ordinary button. Both spellings, so the two paths stay distinguishable. +scroll -3 4 5 +stable 700 10000 +snap scrolled +wheel wheeldown 4 5 +stable 700 10000 +snap notched +# THREE FINGERS = button 2 = Exec. `New` in the top tag is a builtin, so this +# spawns a pane rather than running something the machine would have to own. +fingers 3 1 0 +stable 700 20000 +haptic exec +snap exec +# A DEEP PRESS is the same button 2 by another route; `Newcol` proves it went +# somewhere different from the click above. +force 5 0 +stable 700 20000 +haptic exec +snap force +# One finger is still an ordinary left press, and selecting is not a verb the +# hand should feel. +fingers 1 4 3 +stable 700 10000 +haptic none +snap select +draw trackpad diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift new file mode 100644 index 00000000..42bc8229 --- /dev/null +++ b/test/macos_e2e.swift @@ -0,0 +1,1016 @@ +// 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() + + // `rotate <degrees> [gap_ms]`. The optional gap is a real sleep BEFORE + // the event, and it is the only way a script can say how FAST the dial + // is being turned: libpardes measures the release speed off the + // monotonic clock between events, so back-to-back script calls read as + // an impossibly hard flick. Pace them and a slow twist is genuinely + // slow — which is the half of the momentum contract worth asserting, + // because "no fling" is not something a fling test can show. + case "rotate": + let view = try live() + let degrees = try double(args, 0, "rotate degrees") + if args.count > 1 { + Thread.sleep(forTimeInterval: try double(args, 1, "rotate gap_ms") / 1000) + } + view.rotate(degrees: CGFloat(degrees)) + pump() + + // The fingers coming off. Its own command because the fling is decided + // by the RELEASE SPEED, and a script that could only turn the dial + // could never throw it — NSEvent phases have no public constructor, so + // this entry point is the only way the momentum path is reachable at + // all. The coast is then spent by the pump, at one fixed step per tick. + case "rotate_end": + let view = try live() + view.rotateEnd() + pump() + + // `drop <path> <col> <row>`: a file dropped ON the grid at that cell. + // Same reason as the two above — `NSDraggingInfo` is a protocol with a + // dozen members and no public conformer, so the entry point below the + // event is the only honest way in. What it must prove is the WHERE: a + // drop is a click plus Look, so the document lands beside the pane + // pointed at rather than beside whichever one had focus. + case "drop": + let view = try live() + let path = try token(args, 0, "drop path") + let cell = try gridPoint(args, 1, "drop") + view.drop([path], at: cell) + 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) + } +} diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden index 7e4b3366..a54b8281 100644 --- a/test/snapshots/builtins.golden +++ b/test/snapshots/builtins.golden @@ -100,26 +100,26 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar -| 19 SPC l D WsDiagnostics -| 20 SPC l S WsSymbols -| 21 SPC l a CodeAction -| 22 SPC l d Diagnostics -| 23 SPC l h SelectRefs -| 24 SPC l i Lspinfo -| 25 SPC l k Hover -| 26 SPC l r Rename -| 27 SPC l s Symbols -| 28 SPC l w Lspwhy +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o +| 19 SPC k Kill topbar +| 20 SPC l D WsDiagnostics +| 21 SPC l S WsSymbols +| 22 SPC l a CodeAction +| 23 SPC l d Diagnostics +| 24 SPC l h SelectRefs +| 25 SPC l i Lspinfo +| 26 SPC l k Hover +| 27 SPC l r Rename +| 28 SPC l s Symbols == snap index-tail grid=120x60 cursor=7,58 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del @@ -153,34 +153,34 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 25 SPC l k Hover -| 26 SPC l r Rename -| 27 SPC l s Symbols -| 28 SPC l w Lspwhy -| 29 SPC s d Dump topbar -| 30 SPC s r Restore -| 31 SPC t a Ascii -| 32 SPC t b Tagbottom -| 33 SPC t c Colors -| 34 SPC t d Debug topbar -| 35 SPC t i PdfTint -| 36 SPC t l Palette -| 37 SPC t n NextColor topbar -| 38 SPC t p Petscii -| 39 SPC t r Crt -| 40 SPC t s PdfSections -| 41 SPC t t ThemeSel -| 42 SPC t w Wrap -| 43 SPC t z PdfFit -| 44 SPC w h Left C-w h, C-w left -| 45 SPC w j Down C-w j, C-w down -| 46 SPC w k Up C-w k, C-w up -| 47 SPC w l Right C-w l, C-w right -| 48 Look enter, right-click -| 49 Exec tab, middle-click -| 50 Theme -| 51 Shell -| 52 +| 26 SPC l k Hover +| 27 SPC l r Rename +| 28 SPC l s Symbols +| 29 SPC l w Lspwhy +| 30 SPC s d Dump topbar +| 31 SPC s r Restore +| 32 SPC t a Ascii +| 33 SPC t b Tagbottom +| 34 SPC t c Colors +| 35 SPC t d Debug topbar +| 36 SPC t i PdfTint +| 37 SPC t l Palette +| 38 SPC t n NextColor topbar +| 39 SPC t p Petscii +| 40 SPC t r Crt +| 41 SPC t s PdfSections +| 42 SPC t t ThemeSel +| 43 SPC t w Wrap +| 44 SPC t z PdfFit +| 45 SPC w h Left C-w h, C-w left +| 46 SPC w j Down C-w j, C-w down +| 47 SPC w k Up C-w k, C-w up +| 48 SPC w l Right C-w l, C-w right +| 49 Look enter, right-click +| 50 Exec tab, middle-click +| 51 Theme +| 52 Shell +| 53 == snap not-a-picker grid=120x60 cursor=7,58 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del @@ -214,34 +214,34 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help New Del -| 25 SPC l k Hover -| 26 SPC l r Rename -| 27 SPC l s Symbols -| 28 SPC l w Lspwhy -| 29 SPC s d Dump topbar -| 30 SPC s r Restore -| 31 SPC t a Ascii -| 32 SPC t b Tagbottom -| 33 SPC t c Colors -| 34 SPC t d Debug topbar -| 35 SPC t i PdfTint -| 36 SPC t l Palette -| 37 SPC t n NextColor topbar -| 38 SPC t p Petscii -| 39 SPC t r Crt -| 40 SPC t s PdfSections -| 41 SPC t t ThemeSel -| 42 SPC t w Wrap -| 43 SPC t z PdfFit -| 44 SPC w h Left C-w h, C-w left -| 45 SPC w j Down C-w j, C-w down -| 46 SPC w k Up C-w k, C-w up -| 47 SPC w l Right C-w l, C-w right -| 48 Look enter, right-click -| 49 Exec tab, middle-click -| 50 Theme -| 51 Shell -| 52 +| 26 SPC l k Hover +| 27 SPC l r Rename +| 28 SPC l s Symbols +| 29 SPC l w Lspwhy +| 30 SPC s d Dump topbar +| 31 SPC s r Restore +| 32 SPC t a Ascii +| 33 SPC t b Tagbottom +| 34 SPC t c Colors +| 35 SPC t d Debug topbar +| 36 SPC t i PdfTint +| 37 SPC t l Palette +| 38 SPC t n NextColor topbar +| 39 SPC t p Petscii +| 40 SPC t r Crt +| 41 SPC t s PdfSections +| 42 SPC t t ThemeSel +| 43 SPC t w Wrap +| 44 SPC t z PdfFit +| 45 SPC w h Left C-w h, C-w left +| 46 SPC w j Down C-w j, C-w down +| 47 SPC w k Up C-w k, C-w up +| 48 SPC w l Right C-w l, C-w right +| 49 Look enter, right-click +| 50 Exec tab, middle-click +| 51 Theme +| 52 Shell +| 53 == snap window-group grid=120x60 cursor=7,32 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del @@ -344,23 +344,23 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar -| 19 SPC l D WsDiagnostics -| 20 SPC l S WsSymbols -| 21 SPC l a CodeAction -| 22 SPC l d Diagnostics -| 23 SPC l h SelectRefs -| 24 SPC l i Lspinfo -| 25 SPC l k Hover -| 26 SPC l r Rename -| 27 SPC l s Symbols -| 28 SPC l w Lspwhy +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o +| 19 SPC k Kill topbar +| 20 SPC l D WsDiagnostics +| 21 SPC l S WsSymbols +| 22 SPC l a CodeAction +| 23 SPC l d Diagnostics +| 24 SPC l h SelectRefs +| 25 SPC l i Lspinfo +| 26 SPC l k Hover +| 27 SPC l r Rename +| 28 SPC l s Symbols diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 93e33710..f6b5723b 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -49,12 +49,16 @@ stable 700 15000 snap window-group # back to the whole index, and a NAME in it is live text like anywhere else: # middle-click `Tutor` and the tutor opens. That is the picking a stepping -# picker would have done, minus running the thirty-nine rows you passed. +# picker would have done, minus running the rows you passed. +# +# The one place in this suite a builtin cannot be added for free: the click is +# a SCREEN coordinate, so every row inserted above `SPC h t` in the listing +# moves Tutor down one and this number with it. key space ? wait 10000 SPC c n stable 700 15000 -press middle 20 45 -release middle 20 45 +press middle 20 46 +release middle 20 46 wait 10000 PARDES TUTOR stable 700 15000 snap tutor-from-index diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden index 74719f13..d58e8d6c 100644 --- a/test/snapshots/leader.golden +++ b/test/snapshots/leader.golden @@ -193,16 +193,16 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o == snap help-group grid=100x40 cursor=7,22 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del @@ -357,16 +357,16 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o == snap help-toggles grid=100x40 cursor=7,22 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del diff --git a/test/snapshots/ttyhelp.golden b/test/snapshots/ttyhelp.golden index 69b5961a..a580263b 100644 --- a/test/snapshots/ttyhelp.golden +++ b/test/snapshots/ttyhelp.golden @@ -42,24 +42,24 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar -| 19 SPC l D WsDiagnostics -| 20 SPC l S WsSymbols -| 21 SPC l a CodeAction -| 22 SPC l d Diagnostics -| 23 SPC l h SelectRefs -| 24 SPC l i Lspinfo -| 25 SPC l k Hover -| 26 SPC l r Rename +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o +| 19 SPC k Kill topbar +| 20 SPC l D WsDiagnostics +| 21 SPC l S WsSymbols +| 22 SPC l a CodeAction +| 23 SPC l d Diagnostics +| 24 SPC l h SelectRefs +| 25 SPC l i Lspinfo +| 26 SPC l k Hover == snap leaderworks grid=100x30 cursor=7,4 |New Newcol Find Grep Help Tutor Dump NextColor Debug Kill |$ /tmp/pardes-snap/ttyhelp/cwd New Del @@ -73,21 +73,21 @@ | 6 SPC c d Delcol | 7 SPC c n Newcol topbar | 8 SPC d Del -| 9 SPC f f Find topbar -| 10 SPC f g Grep topbar -| 11 SPC f n New topbar -| 12 SPC f s Save -| 13 SPC h t Tutor topbar -| 14 SPC j i Forward C-i -| 15 SPC j j Last -| 16 SPC j l Jumplist -| 17 SPC j o Back C-o -| 18 SPC k Kill topbar -| 19 SPC l D WsDiagnostics -| 20 SPC l S WsSymbols -| 21 SPC l a CodeAction -| 22 SPC l d Diagnostics -| 23 SPC l h SelectRefs -| 24 SPC l i Lspinfo -| 25 SPC l k Hover -| 26 SPC l r Rename +| 9 SPC f c Config +| 10 SPC f f Find topbar +| 11 SPC f g Grep topbar +| 12 SPC f n New topbar +| 13 SPC f s Save +| 14 SPC h t Tutor topbar +| 15 SPC j i Forward C-i +| 16 SPC j j Last +| 17 SPC j l Jumplist +| 18 SPC j o Back C-o +| 19 SPC k Kill topbar +| 20 SPC l D WsDiagnostics +| 21 SPC l S WsSymbols +| 22 SPC l a CodeAction +| 23 SPC l d Diagnostics +| 24 SPC l h SelectRefs +| 25 SPC l i Lspinfo +| 26 SPC l k Hover |
