summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/e2e_harness.zig28
-rw-r--r--test/fs.py14
-rw-r--r--test/gui_monkey.py179
-rw-r--r--test/monkey9p.py16
4 files changed, 228 insertions, 9 deletions
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index eaa19b43..9e67c49f 100644
--- a/test/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -64,6 +64,8 @@ pub const Harness = struct {
/// when true, print the captured screen state after each pump/waitFor and on
/// every assertion, so live test runs can be inspected (zig build test -Dtrace).
trace: bool = false,
+ /// How much of a `CSI 5 n` the output has ended on (`feed`).
+ status_request: u8 = 0,
/// every raw byte the app has emitted (accumulated in pump). Lets tests assert
/// on control sequences the emulator consumes and never renders (e.g. OSC 52
/// clipboard writes). Capture is bounded explicitly so a runaway child cannot
@@ -147,6 +149,24 @@ pub const Harness = struct {
/// Read pty output and feed it to our ghostty terminal for `ms` ms. After
/// this, the grid reflects everything the app rendered so far.
+ /// The app's output into the emulator. The one query answered is the
+ /// device status request (`CSI 5 n` → `CSI 0 n`), as every terminal
+ /// does: the tty asks it after each frame and draws no other until the
+ /// answer (FrameAck in src/tty/tty.zig).
+ fn feed(self: *Harness, bytes: []const u8) void {
+ self.stream.nextSlice(bytes);
+ const request = "\x1b[5n";
+ for (bytes) |b| {
+ if (b == request[self.status_request]) {
+ self.status_request += 1;
+ } else self.status_request = if (b == 0x1b) 1 else 0;
+ if (self.status_request == request.len) {
+ self.status_request = 0;
+ _ = libc.write(self.master, "\x1b[0n", 4);
+ }
+ }
+ }
+
pub fn pump(self: *Harness, ms: i64) !void {
const deadline = nowMs() + ms;
var buf: [4096]u8 = undefined;
@@ -157,7 +177,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
}
if (self.trace) self.traceScreen("pump");
@@ -176,7 +196,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch return false;
if (n == 0) return false;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
return true;
}
@@ -243,7 +263,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
const text = try self.screenText();
defer self.gpa.free(text);
@@ -270,7 +290,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true;
}
return false;
diff --git a/test/fs.py b/test/fs.py
index 15f4eb8b..36ba180d 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -953,6 +953,20 @@ def test(binary, quic=False):
with Client(address, msize=256) as small:
assert set(small.list('/os' + str(listing))) == {
f'entry-{number:02}' for number in range(24)}
+ # 64 KiB frames: a ctl line with no newline, whole in its one
+ # Twrite, runs with it however long -- the cutoff for a write
+ # that may go on is the frame the client asked for, less 24.
+ with Client(address, msize=65536) as big:
+ assert big.msize == 65536
+ ctl = big.open('/pane/1/ctl', 1)
+ line = b'bogus' + b'x' * 20000
+ try:
+ big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ except OSError:
+ pass # refused with its write (a line over 1024 bytes), not held
+ else:
+ raise AssertionError('a whole 20000-byte line waited for a newline')
+ big.close(ctl)
frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
diff --git a/test/gui_monkey.py b/test/gui_monkey.py
new file mode 100644
index 00000000..2b32f050
--- /dev/null
+++ b/test/gui_monkey.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden
+test window gets a random burst of clicks, drags, wheels, keys, new and
+joined columns, opened files and theme changes, then must settle (its
+frames stop changing) with no crash record and its process alive.
+
+gui_monkey.py <pardes-gui> [outdir] [--seed N] [--steps N]; `zig build
+monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built
+window. Exits 1 when any combination failed; what failed is in outdir.
+"""
+import argparse
+import hashlib
+import itertools
+import os
+import random
+import shutil
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from fs import session # noqa: E402
+
+MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful']
+LIFTS = ['off', 'shadow', 'rim', 'auto']
+BLOOMS = ['0', '2']
+THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak']
+W, H = 1280, 840
+
+
+def settled(cap, timeout=20):
+ latest = cap / 'latest.ppm'
+ previous = None
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ time.sleep(.4)
+ try:
+ data = latest.read_bytes()
+ except FileNotFoundError:
+ continue
+ digest = hashlib.sha256(data).hexdigest()
+ if digest == previous:
+ return True
+ previous = digest
+ return False
+
+
+def run(binary, out, motion, lift, bloom, seed, steps):
+ rng = random.Random(seed)
+ root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-'))
+ cap = root / 'cap'
+ cap.mkdir()
+ (root / 'config/pardes').mkdir(parents=True)
+ (root / 'config/pardes/init').write_text(
+ f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n')
+ src = root / 'a.zig'
+ src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200)))
+ for name in ('b.txt', 'c.txt'):
+ (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80)))
+ display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0')
+ env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0',
+ 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
+ name = 'monkey'
+ launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)]
+ handles = {}
+ verdict = 'ok'
+ log = []
+ try:
+ with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _):
+ fd = handles['input_fd']
+ time.sleep(1.0)
+
+ def mouse(kind, button, x, y):
+ os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode())
+
+ def first():
+ rows = client.read('/index').decode().splitlines()
+ return int(rows[0].split()[0]) if rows else None
+
+ for _ in range(steps):
+ x, y = rng.randrange(W), rng.randrange(H)
+ action = rng.randrange(12)
+ try:
+ if action in (0, 1):
+ mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}')
+ elif action == 2:
+ x2, y2 = rng.randrange(W), rng.randrange(H)
+ mouse('down', 1, x, y)
+ for i in range(1, 6):
+ mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5)
+ mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}')
+ elif action == 3:
+ amount = rng.choice([-6, -2, 2, 6])
+ os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}')
+ elif action == 4:
+ mouse('motion', 0, x, y); log.append(f'hover {x},{y}')
+ elif action == 5:
+ client.write('/ctl', b'Newcol\n'); log.append('Newcol')
+ elif action == 6:
+ serial = first()
+ if serial is not None:
+ target = root / rng.choice(['b.txt', 'c.txt', 'a.zig'])
+ client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}')
+ elif action == 7:
+ serial = first()
+ if serial is not None:
+ # Delcol too: the last column's going leaves an
+ # empty window now (acme's), not a quit.
+ word = rng.choice(['Joincol', 'Collapse', 'Delcol'])
+ try:
+ client.write(f'/pane/{serial}/exec', (word + '\n').encode())
+ except OSError:
+ pass
+ log.append(word)
+ else:
+ # An empty window: pane/new makes its column and a pane.
+ try:
+ client.read('/pane/new')
+ except OSError:
+ pass
+ log.append('pane/new (empty window)')
+ elif action == 8:
+ os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b']))
+ log.append('keys')
+ elif action == 9:
+ theme = rng.choice(THEMES)
+ client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}')
+ elif action == 10:
+ mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}')
+ else:
+ time.sleep(rng.choice([0, .05, .2])); log.append('pause')
+ except OSError as why:
+ log.append(f'refused: {why}')
+ time.sleep(.03)
+ if not settled(cap):
+ verdict = 'never settled'
+ try:
+ os.kill(handles['pid'], 0)
+ except OSError:
+ verdict = 'process gone'
+ client.read('/index')
+ except Exception as why: # noqa: BLE001
+ verdict = f'failed: {type(why).__name__}: {str(why)[:300]}'
+ crashes = list((root / 'config').rglob('*crash*'))
+ if crashes:
+ verdict = 'crash record: ' + ', '.join(str(c) for c in crashes)
+ label = f'{motion}-{lift}-bloom{bloom}'
+ if verdict != 'ok':
+ (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n')
+ for c in crashes:
+ shutil.copy(c, out / f'{label}-{c.name}')
+ if (cap / 'latest.ppm').exists():
+ shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm')
+ shutil.rmtree(root, ignore_errors=True)
+ return label, verdict
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('binary', type=Path)
+ ap.add_argument('out', type=Path, nargs='?')
+ ap.add_argument('--seed', type=int, default=1)
+ ap.add_argument('--steps', type=int, default=60)
+ args = ap.parse_args()
+ if args.out is None:
+ args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-'))
+ args.out.mkdir(parents=True, exist_ok=True)
+ bad = 0
+ for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)):
+ label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps)
+ print(f'{label}: {verdict}', flush=True)
+ bad += verdict != 'ok'
+ print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True)
+ sys.exit(1 if bad else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/monkey9p.py b/test/monkey9p.py
index c0e99fb8..ee0b0ebc 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -641,11 +641,11 @@ def op_write(sess, op):
ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout)
res.add(open_kind(path, mode), path, ok, None if ok else body)
sizes = chunking(op.get('chunks', 'whole'), len(data), w.msize - 23)
- allowances(res, path, data, sizes, w.msize - 24)
opened = ok
sent = data
if ok:
offset = 0
+ took = []
for size in sizes:
piece = data[offset:offset + size]
ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout)
@@ -654,8 +654,13 @@ def op_write(sess, op):
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
offset += size
sent = data[:offset]
+ if ok:
+ took.append(size)
if not ok and op.get('stop_on_error', True):
break
+ # Misses are allowed the Twrites that succeeded: a line written a
+ # byte a Twrite is a look a byte, each its own write.
+ allowances(res, path, data[:sum(took)], took, w.msize - 24)
ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout)
res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body)
# What the open holds when it closes: the last line written, if unended.
@@ -850,11 +855,12 @@ def op_hwrite(sess, op):
fid, path, mode, off, _ = h
data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite
h[4] = data
- allowances(res, path, data, [len(data)], sess.wire.msize - 24)
ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data,
SLOW_TIMEOUT if slow(data) else TIMEOUT)
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
if ok:
+ allowances(res, path, data, [len(data)], sess.wire.msize - 24)
+ if ok:
off[0] += len(data)
return res
@@ -1498,9 +1504,9 @@ def one_failure_rule(ctx, res):
and w[1].rstrip('/') != '/pane/new']
errs = occurrences(res.window, 'err')
msgs = occurrences(res.window, 'msg')
- # Only for a write that succeeded: a failed one logs its one err.
- failed_paths = {w[1] for w in failed}
- allowed = sum(r[3] for r in res.requests if r[0] == 'allow' and r[1] not in failed_paths)
+ # Allowed only the Twrites that succeeded (allowances): a failed one
+ # logs its one err.
+ allowed = sum(r[3] for r in res.requests if r[0] == 'allow')
# fs.md 'A write of command lines ... what is left when it closes runs at
# the close ... and its failure is in the log alone'.
closes = [r for r in res.requests if r[0] == 'close-runs']