diff options
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/font.golden | 179 | ||||
| -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 | 156 | ||||
| -rw-r--r-- | test/macos-snapshots/rotate.snap | 63 | ||||
| -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 | 983 |
14 files changed, 2438 insertions, 1 deletions
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index c188827e..8dac7836 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..28e5a1de --- /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 720x408 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 900x510 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/font.golden b/test/macos-snapshots/font.golden new file mode 100644 index 00000000..e61abecd --- /dev/null +++ b/test/macos-snapshots/font.golden @@ -0,0 +1,179 @@ +== 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=81x19 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=225x58 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..54f0d217 --- /dev/null +++ b/test/macos-snapshots/rotate.golden @@ -0,0 +1,156 @@ +== snap results grid=100x30 cursor=4,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/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 forward grid=100x30 cursor=7,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/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 forward2 grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/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 back grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/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 forward_by_key grid=100x30 cursor=7,7 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/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 +== draw rotate 900x510 nonblank diff --git a/test/macos-snapshots/rotate.snap b/test/macos-snapshots/rotate.snap new file mode 100644 index 00000000..17b239f4 --- /dev/null +++ b/test/macos-snapshots/rotate.snap @@ -0,0 +1,63 @@ +# 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 20 degrees (rotation_notch_degrees in src/macos.zig) and the +# remainder is banked, which makes the pair below exact rather than approximate: +# -25 spends one notch and banks -5, then +25 lands on 20 and spends one back. +start 30 100 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +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 +key c-b +stable 600 8000 +# `/` types the pattern into the tag; Enter searches the pane's scrollback and +# writes the three 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 -25 +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 -25 +stable 700 15000 +snap forward2 +# A new gesture, because the dial banks its remainder exactly like the scroll +# accumulator does: two 25-degree notches leave -10 behind, so reversing INSIDE +# the same twist would have to spend that first and 25 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 25 +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 +draw rotate diff --git a/test/macos-snapshots/trackpad.golden b/test/macos-snapshots/trackpad.golden new file mode 100644 index 00000000..82edf68b --- /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 900x510 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..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) + } +} |
