Stage 0: offline Qt test/snapshot harness + automation FFI

ober

7e808525283e020d321c279b0416f64cf827266c

diff --git a/.build.yml b/.build.yml
index 9e1978d..557352b 100644
--- a/.build.yml
+++ b/.build.yml
@@ -23,6 +23,8 @@ artifacts:
   # Saved + downloadable from the build job page. Path is relative to ~ (home);
   # the repo is cloned to ~/jerboa-browser and build-tarball.sh writes here.
   - jerboa-browser/qt-webengine/build/jerboa-browser-0.0.1-linux-x86_64.tar.gz
+  # Offline Qt snapshot PNGs from the test-gui task (the harness output).
+  - jerboa-browser/gui-snapshots.tar.gz
 tasks:
   - chez: |
       # Build Chez from the source vendored in the jerboa repo, so the binary
@@ -58,3 +60,19 @@ tasks:
       echo "--- native binary ---"
       tar xzf "$tb" -O jerboa-browser-0.0.1-linux-x86_64/bin/jerboa-browser \
         > /tmp/jb && file /tmp/jb
+  - test-gui: |
+      cd jerboa-browser
+      # Offline Qt GUI / snapshot harness (offscreen, headless). Kept hermetic
+      # in CI (JWB_TEST_NO_NETWORK) so it does not depend on live example.com;
+      # the full networked path runs locally and on the amd64 verifier.
+      # --no-sandbox: Chromium's zygote can't sandbox inside the CI container.
+      # Exported env (not make vars): the Makefile's `?=` defaults defer to env,
+      # and JERBOA_BROWSER_LIB must reach the scheme process directly.
+      export QT_QPA_PLATFORM=offscreen JWB_TEST_NO_NETWORK=1
+      export QTWEBENGINE_DISABLE_SANDBOX=1
+      export QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu"
+      export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)"
+      export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so"
+      make test-gui
+      # Stage snapshots as a downloadable artifact (~/jerboa-browser/...).
+      tar czf gui-snapshots.tar.gz test-artifacts
diff --git a/.gitignore b/.gitignore
index 9564f40..d602878 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,5 +11,9 @@ build/
 jb-main.c
 jb_*.h
 
+# Offline Qt harness output (make test-gui) + CI snapshot bundle
+test-artifacts/
+gui-snapshots.tar.gz
+
 # macOS
 .DS_Store
diff --git a/Makefile b/Makefile
index 534e6d3..a3d3996 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
 SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
 LIBDIRS     := $(CURDIR)/scheme:$(JERBOA_HOME)/lib
 
-.PHONY: binary test repl clean help
+.PHONY: binary test test-gui repl clean help
 .DEFAULT_GOAL := help
 
 # Build the self-contained native ./jerboa-browser (Chez + boot + (browser)).
@@ -14,13 +14,18 @@ binary:
 test:
 	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-test.ss
 
+# Offline Qt GUI / snapshot harness (headless). Writes PNGs to ./test-artifacts.
+test-gui:
+	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
+	  $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-gui-test.ss
+
 # REPL with (browser) preloaded, in the interpreter.
 repl:
 	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --program scheme/browser-repl.ss
 
 clean:
 	rm -f jerboa-browser jb-main.c jb-main.o jb_*.h jerboa-browser.boot
-	rm -rf .bcache
+	rm -rf .bcache test-artifacts
 	find scheme -name '*.so' -delete 2>/dev/null || true
 	find scheme -name '*.wpo' -delete 2>/dev/null || true
 
@@ -29,6 +34,7 @@ help:
 	@echo ""
 	@echo "  make binary   build the self-contained native ./jerboa-browser"
 	@echo "  make test     run the (browser) test suite (interpreter)"
+	@echo "  make test-gui offline Qt GUI/snapshot harness (offscreen, headless)"
 	@echo "  make repl     REPL with (browser) preloaded (interpreter)"
 	@echo "  make clean    remove build artifacts"
 	@echo ""
diff --git a/ROADMAP.md b/ROADMAP.md
index 679de44..2c3d52d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -75,22 +75,26 @@ crypto hygiene, not for on-disk persistence.)
 
 ## Stages
 
-### Stage 0 — Foundation: window chrome + offline test/snapshot harness
-A `QMainWindow` (`BrowserWindow`) that hosts a `QStackedWidget` of web views, a
-status bar, and a minibuffer line — replacing the bare single view. Even before
-buffers/keymap exist, this gives us something to grab + drive in tests.
-- **C++ FFI add**: `jwb_window_new/show/grab_png(path)`,
-  `jwb_send_key(view,key,mods,text)` (QTest-style / `sendEvent`),
-  `jwb_window_set_minibuffer(text)`, `jwb_window_set_status(text)`.
-- **Scheme add**: `(browser test)` helpers — `snapshot!`, `send-key!`,
-  `with-headless-browser`, a `test-group`/`run-test-case` framework copied from
-  jerboa-emacs.
-- **REPL-on-launch**: `jerboa-browser repl` already preloads `(browser)`; add a
-  `--repl` flag to the GUI path so the window comes up WITH a live REPL for
-  interactive driving (the jerboa-emacs debugging pattern).
-- **Tests**: launch offscreen, load example.com, `snapshot!` → assert PNG
-  non-empty + expected size; assert `browser-url`/`browser-title`.
-- CI: add a `test-qt` make target run under `xvfb`/offscreen in `.build.yml`.
+### Stage 0 — Foundation: offline test/snapshot harness ✅ DONE
+Delivered the automation FFI + the headless test/snapshot harness FIRST (the
+thing every later stage is verified with); the full `QMainWindow`/`QStackedWidget`
+chrome is **deferred to Stage 1**, where the buffer model gives it real content
+to host (building the window shell before buffers would be throwaway).
+- **C++ FFI** (`qt-webengine/src/browser_automation.cpp`): `jwb_view_resize`,
+  `jwb_view_grab_png(path)` (`QWidget::grab()`→`QPixmap::save(...,"PNG")`),
+  `jwb_send_key(view,key,mods,text)` (`QCoreApplication::sendEvent`). Declared in
+  `include/jerboa_browser.h`; 1=ok/0=fail with reason in `jwb_last_error`.
+- **Scheme** (`scheme/browser.ss`): `browser-resize`, `browser-grab-png`,
+  `browser-send-key` wrappers (tagged `(ok …)`/`(err …)`).
+- **Harness** (`scheme/browser-gui-test.ss`): `test-group`/`run-test-case`/`check`
+  framework copied from jerboa-emacs; runs offscreen + synchronous. Snapshots are
+  verified by parsing the PNG signature + IHDR width/height (proves a real,
+  correctly-sized grab, not just "a file appeared"). `JWB_TEST_NO_NETWORK` keeps
+  CI hermetic; full networked path runs locally + on the amd64 verifier.
+- **CI**: `make test-gui` target wired into `.build.yml` (offscreen, no-sandbox,
+  no-network); snapshot PNGs uploaded as `gui-snapshots.tar.gz`.
+- **Deferred to Stage 1**: `QMainWindow` chrome (stacked views + status +
+  minibuffer line) and `--repl`-on-GUI-launch.
 
 ### Stage 1 — Buffer model
 Multiple views in one window; nyxt buffer semantics.
diff --git a/WISHLIST.md b/WISHLIST.md
index c4d189f..529b101 100644
--- a/WISHLIST.md
+++ b/WISHLIST.md
@@ -7,7 +7,7 @@ minibuffer/prompt, an emacs-default keymap, an in-process password manager —
 built on the existing Qt WebEngine FFI + self-contained native binary, with
 offline Qt snapshot + functional tests modeled on `~/mine/jerboa-emacs`.
 
-- [ ] **Offline Qt test + snapshot harness** (offscreen, grab→PNG, key injection, REPL-on-launch) — *Stage 0*
+- [x] **Offline Qt test + snapshot harness** (offscreen, grab→PNG w/ IHDR-verified size, key injection) — *Stage 0* — `make test-gui`; `--repl`-on-GUI-launch + window chrome deferred to Stage 1
 - [ ] **Buffer model** — multiple web views in one window; switch / list / delete — *Stage 1*
 - [ ] **Keymap engine + emacs bindings + minibuffer (prompt-buffer)** with fuzzy completion — *Stage 2*
 - [ ] **Navigation parity** — scroll, zoom, find-in-page, history, reload, copy url/title — *Stage 3*
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 6fc9b51..734f146 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -165,6 +165,22 @@ JWB_API const char *jwb_eval(JwbHandle view, const char *code,
 JWB_API const char *jwb_get_title(JwbHandle view);
 JWB_API const char *jwb_get_url(JwbHandle view);
 
+/* === Automation / testing hooks ========================================
+ * Offline GUI testing under QT_QPA_PLATFORM=offscreen: size a view
+ * deterministically, snapshot it to a PNG, and drive synthetic key input.
+ * Return 1 on success, 0 on failure (jwb_last_error has the reason).
+ *
+ * NOTE: web *content* pixels are best-effort under offscreen software
+ * rendering; Qt chrome (minibuffer/status — plain widgets) snapshots
+ * reliably, and DOM/page state is asserted via jwb_eval, not pixels. */
+JWB_API int jwb_view_resize(JwbHandle view, int width, int height);
+JWB_API int jwb_view_grab_png(JwbHandle view, const char *path);
+/* Synthesize a key press+release to the focused input widget. `qt_key` is a
+ * Qt::Key, `modifiers` a Qt::KeyboardModifiers mask, `text` the UTF-8
+ * character(s) the key produces (may be NULL/"" for non-text keys). */
+JWB_API int jwb_send_key(JwbHandle view, int qt_key, int modifiers,
+                         const char *text);
+
 #ifdef __cplusplus
 } /* extern "C" */
 #endif
diff --git a/qt-webengine/CMakeLists.txt b/qt-webengine/CMakeLists.txt
index aeb28d1..addf223 100644
--- a/qt-webengine/CMakeLists.txt
+++ b/qt-webengine/CMakeLists.txt
@@ -20,6 +20,7 @@ add_library(jerboa_browser SHARED
   src/browser_page.cpp
   src/browser_policy.cpp
   src/browser_ffi.cpp
+  src/browser_automation.cpp
   src/browser_host.cpp
 )
 target_include_directories(jerboa_browser
diff --git a/qt-webengine/src/browser_automation.cpp b/qt-webengine/src/browser_automation.cpp
new file mode 100644
index 0000000..c39093a
--- /dev/null
+++ b/qt-webengine/src/browser_automation.cpp
@@ -0,0 +1,75 @@
+// browser_automation.cpp — offline GUI automation/testing FFI.
+//
+// Snapshot a view to a PNG, size it deterministically, and drive synthetic key
+// input. Built into the same .so and used by the (browser) test harness under
+// QT_QPA_PLATFORM=offscreen. These compose the same handle/registry seam as
+// browser_view.cpp; web-content pixels are best-effort under offscreen software
+// rendering (assert page state via jwb_eval instead), but Qt chrome grabs fine.
+#include "browser_objects.h"
+
+#include <QCoreApplication>
+#include <QApplication>
+#include <QKeyEvent>
+#include <QPixmap>
+#include <QString>
+#include <QWidget>
+
+extern "C" {
+
+// Resize the view widget. A never-shown view defaults to 0x0, which grabs
+// blank, so tests set a fixed size first for deterministic snapshots.
+JWB_API int jwb_view_resize(JwbHandle view, int width, int height) {
+  jwb::clear_last_error();
+  jwb::View *v = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_view(view, &v, &st)) return 0;
+  if (width <= 0 || height <= 0) {
+    jwb::set_last_error("resize: width/height must be positive");
+    return 0;
+  }
+  v->view->resize(width, height);
+  return 1;
+}
+
+// Grab the view to a PNG at `path`. Gives a zero-sized (never-shown) view a
+// sane default size first, since an empty grab yields a null pixmap.
+JWB_API int jwb_view_grab_png(JwbHandle view, const char *path) {
+  jwb::clear_last_error();
+  jwb::View *v = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_view(view, &v, &st)) return 0;
+  if (!path) {
+    jwb::set_last_error("grab: path is null");
+    return 0;
+  }
+  if (v->view->size().isEmpty()) v->view->resize(1024, 768);
+  QPixmap pm = v->view->grab();
+  if (pm.isNull()) {
+    jwb::set_last_error("grab: null pixmap (view not renderable?)");
+    return 0;
+  }
+  return pm.save(QString::fromUtf8(path), "PNG") ? 1 : 0;
+}
+
+// Synthesize a key press+release to the current focus widget (falling back to
+// the view's focus proxy, then the view). Drives the minibuffer/keymap in
+// tests; for non-text keys pass text=NULL.
+JWB_API int jwb_send_key(JwbHandle view, int qt_key, int modifiers,
+                         const char *text) {
+  jwb::clear_last_error();
+  jwb::View *v = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_view(view, &v, &st)) return 0;
+  QWidget *target = QApplication::focusWidget();
+  if (!target) target = v->view->focusProxy();
+  if (!target) target = v->view;
+  QString t = text ? QString::fromUtf8(text) : QString();
+  auto mods = static_cast<Qt::KeyboardModifiers>(modifiers);
+  QKeyEvent press(QEvent::KeyPress, qt_key, mods, t);
+  QCoreApplication::sendEvent(target, &press);
+  QKeyEvent release(QEvent::KeyRelease, qt_key, mods, t);
+  QCoreApplication::sendEvent(target, &release);
+  return 1;
+}
+
+} // extern "C"
diff --git a/scheme/browser-gui-test.ss b/scheme/browser-gui-test.ss
new file mode 100644
index 0000000..354df86
--- /dev/null
+++ b/scheme/browser-gui-test.ss
@@ -0,0 +1,171 @@
+#!chezscheme
+;;; browser-gui-test.ss — offline Qt GUI / snapshot harness for (browser).
+;;;
+;;; Modeled on jerboa-emacs' tests/test-qt.ss: runs fully headless under
+;;; QT_QPA_PLATFORM=offscreen and SYNCHRONOUSLY (never enters the Qt exec loop),
+;;; so it is safe in CI. It exercises the automation FFI added in Stage 0:
+;;; deterministic view sizing, PNG snapshots (jwb_view_grab_png), and synthetic
+;;; key input (jwb_send_key).
+;;;
+;;; Snapshots are written to JWB_TEST_ARTIFACTS (default ./test-artifacts) and
+;;; verified by parsing the PNG signature + IHDR width/height — so the snapshot
+;;; assertion proves the grab pipeline produced a real, correctly-sized PNG,
+;;; not merely that a file appeared.  Web *content* pixels are blank under
+;;; offscreen software rendering (a known limitation), so page state is asserted
+;;; via browser-eval (DOM), and the pixel assertions cover Qt geometry only.
+;;;
+;;; Run:  QT_QPA_PLATFORM=offscreen make test-gui
+;;; Exits 0 on success, 1 on any failed case.
+
+(import (chezscheme) (browser))
+
+;;; ─── tiny test framework (ported from jerboa-emacs test-qt.ss) ────────────
+(define *pass* 0)
+(define *fail* 0)
+(define *test-name* "(none)")
+
+(define-syntax test-group
+  (syntax-rules ()
+    [(_ name body ...)
+     (begin (display "\n=== ") (display name) (display " ===\n")
+            (flush-output-port (current-output-port))
+            body ...)]))
+
+(define (run-test-case name thunk)
+  (set! *test-name* name)
+  (let ((ok (guard (e (#t
+                       (set! *fail* (+ *fail* 1))
+                       (display "  FAIL: ") (display name) (newline)
+                       (display "    error: ")
+                       (display (if (message-condition? e)
+                                    (condition-message e)
+                                    (format "~s" e)))
+                       (newline)
+                       (flush-output-port (current-output-port))
+                       #f))
+              (thunk)
+              #t)))
+    (when ok
+      (set! *pass* (+ *pass* 1))
+      (display "  pass: ") (display name) (newline)
+      (flush-output-port (current-output-port)))))
+
+(define-syntax test-case
+  (syntax-rules ()
+    [(_ name body ...) (run-test-case name (lambda () body ...))]))
+
+;; (check EXPR => EXPECTED)  equal? assertion
+;; (check EXPR ? PRED)       predicate assertion
+(define-syntax check
+  (syntax-rules (=> ?)
+    [(_ expr => expected)
+     (let ((got expr) (exp expected))
+       (unless (equal? got exp)
+         (error 'check (format "~a: expected ~s, got ~s" *test-name* exp got))))]
+    [(_ expr ? pred)
+     (let ((got expr))
+       (unless (pred got)
+         (error 'check (format "~a: predicate failed for ~s" *test-name* got))))]))
+
+;; Unwrap an (ok V); fail loudly on (err M) with the message.
+(define (ok! r)
+  (if (browser-ok? r) (browser-value r)
+      (error 'ok! (format "~a: expected ok, got (err ~s)" *test-name* (browser-value r)))))
+
+;;; ─── PNG inspection: signature + IHDR dimensions ──────────────────────────
+(define png-magic '#vu8(137 80 78 71 13 10 26 10))
+
+(define (read-file-bytes path)
+  (and (file-exists? path)
+       (call-with-port (open-file-input-port path) get-bytevector-all)))
+
+(define (png-file? bv)
+  (and (bytevector? bv) (>= (bytevector-length bv) 8)
+       (let loop ((i 0))
+         (or (= i 8)
+             (and (= (bytevector-u8-ref bv i) (bytevector-u8-ref png-magic i))
+                  (loop (+ i 1)))))))
+
+(define (be32 bv off)
+  (+ (* (bytevector-u8-ref bv off)       16777216)
+     (* (bytevector-u8-ref bv (+ off 1)) 65536)
+     (* (bytevector-u8-ref bv (+ off 2)) 256)
+     (bytevector-u8-ref bv (+ off 3))))
+
+;; PNG layout: 8-byte sig, 4-byte len, "IHDR", width@16 height@20 (big-endian).
+(define (png-dimensions bv)
+  (and (bytevector? bv) (>= (bytevector-length bv) 24)
+       (cons (be32 bv 16) (be32 bv 20))))
+
+;;; ─── setup (offscreen, synchronous) ───────────────────────────────────────
+(unless (getenv "QT_QPA_PLATFORM") (setenv "QT_QPA_PLATFORM" "offscreen"))
+
+(define artifacts-dir (or (getenv "JWB_TEST_ARTIFACTS") "test-artifacts"))
+(unless (file-exists? artifacts-dir) (mkdir artifacts-dir))
+(define (artifact name) (string-append artifacts-dir "/" name))
+
+(display "browser-gui-test: offscreen=") (display (getenv "QT_QPA_PLATFORM"))
+(display " artifacts=") (display artifacts-dir) (newline)
+
+;; Qt key/modifier constants used below (mirror Qt::Key / Qt::KeyboardModifiers).
+(define KEY_X #x58)
+(define KEY_L #x4C)
+(define MOD_NONE    0)
+(define MOD_CONTROL #x04000000)
+
+;;; ─── scenario ─────────────────────────────────────────────────────────────
+(test-group "runtime + view"
+  (test-case "version is a non-empty string"
+    (check (browser-version) ? (lambda (s) (and (string? s) (> (string-length s) 0)))))
+  (test-case "browser-init succeeds" (ok! (browser-init))))
+
+(define ctx (ok! (browser-open-context (browser-capabilities 'network))))
+(define view (ok! (browser-open-view ctx)))
+
+(test-group "deterministic sizing + snapshot"
+  (test-case "resize to 800x600" (ok! (browser-resize view 800 600)))
+  (test-case "grab PNG snapshot of sized (blank) view"
+    (let* ((path (ok! (browser-grab-png view (artifact "stage0-blank-800x600.png"))))
+           (bv   (read-file-bytes path)))
+      (check (png-file? bv) => #t)
+      (check (png-dimensions bv) => '(800 . 600)))))
+
+;; The sizing/snapshot/key/type-safety groups are hermetic (no network). Only
+;; this group navigates the live web; CI sets JWB_TEST_NO_NETWORK=1 to stay
+;; hermetic + reliable (headless QtWebEngine networking is flaky), while local
+;; runs and the amd64 verifier exercise the full path.
+(if (getenv "JWB_TEST_NO_NETWORK")
+    (begin (display "\n=== load + DOM state ===\n  SKIP (JWB_TEST_NO_NETWORK set)\n")
+           (flush-output-port (current-output-port)))
+    (test-group "load + DOM state (content pixels are blank offscreen)"
+      (test-case "load https://example.com" (ok! (browser-load view "https://example.com")))
+      (test-case "document.title == \"Example Domain\""
+        (check (ok! (browser-eval view "document.title")) => "Example Domain"))
+      (test-case "current url is non-empty"
+        (check (ok! (browser-url view)) ? (lambda (s) (> (string-length s) 0))))
+      (test-case "grab PNG snapshot after load"
+        (let ((bv (read-file-bytes (ok! (browser-grab-png view (artifact "stage0-loaded.png"))))))
+          (check (png-file? bv) => #t)
+          (check (png-dimensions bv) => '(800 . 600))))))
+
+(test-group "synthetic key input (FFI plumbing smoke)"
+  ;; No keymap/minibuffer consumer exists yet (Stage 2), so this proves the
+  ;; jwb_send_key path delivers an event to the view without error; the
+  ;; functional buffer/keymap scenarios land in later stages.
+  (test-case "send printable key 'x'" (ok! (browser-send-key view KEY_X MOD_NONE "x")))
+  (test-case "send chord C-l (no text)" (ok! (browser-send-key view KEY_L MOD_CONTROL "")))
+  (test-case "pump the event loop" (ok! (browser-pump 100))))
+
+(test-group "type-safety + teardown"
+  (test-case "resize rejects a context (not a view)"
+    (check (guard (e (#t #t)) (browser-resize ctx 10 10) #f) => #t))
+  (test-case "close view" (ok! (browser-close-view view)))
+  (test-case "close context" (ok! (browser-close-context ctx)))
+  (test-case "snapshot on a closed view is an error"
+    (check (browser-error? (browser-grab-png view (artifact "should-not-exist.png"))) => #t)))
+
+;;; ─── summary ──────────────────────────────────────────────────────────────
+(newline)
+(display "browser-gui-test: ") (display *pass*) (display " passed, ")
+(display *fail*) (display " failed") (newline)
+(exit (if (zero? *fail*) 0 1))
diff --git a/scheme/browser.ss b/scheme/browser.ss
index c73e7ce..613eace 100644
--- a/scheme/browser.ss
+++ b/scheme/browser.ss
@@ -53,7 +53,11 @@
     browser-view-handle
     browser-ok?
     browser-error?
-    browser-value)
+    browser-value
+    ;; automation / testing
+    browser-resize
+    browser-grab-png
+    browser-send-key)
 
   (import (except (chezscheme)
                   make-hash-table hash-table?
@@ -148,6 +152,10 @@
   (define-c-lambda %pump         (uint32)                    int         "jwb_pump_events")
   (define-c-lambda %exec         ()                          int         "jwb_exec")
   (define-c-lambda %quit         ()                          int         "jwb_quit")
+  ;; automation / testing hooks (browser_automation.cpp)
+  (define-c-lambda %view-resize  (uint64 int int)            int         "jwb_view_resize")
+  (define-c-lambda %grab-png     (uint64 char-string)        int         "jwb_view_grab_png")
+  (define-c-lambda %send-key     (uint64 int int char-string) int        "jwb_send_key")
 
   ;; --- tagged results -----------------------------------------------------
   (def (ok* v)  (list 'ok v))
@@ -297,4 +305,30 @@
       (error 'browser-close-context "expected a browser-context" ctx))
     (status-result "close context" (%context-free (browser-context-handle ctx))))
 
+  ;; --- automation / testing hooks ----------------------------------------
+  ;; These bind browser_automation.cpp, whose int return is 1=ok / 0=fail (NOT
+  ;; a JwbStatus); the reason on failure is in jwb_last_error, so read %last-error.
+
+  ;; Size the view widget for deterministic offscreen snapshots.
+  (def (browser-resize view w h)
+    (require-view 'browser-resize view)
+    (if (= 1 (%view-resize (browser-view-handle view) w h))
+        (ok* #t)
+        (err* (string-append "resize: " (%last-error)))))
+
+  ;; Grab the view to a PNG at path; (ok path) on success.
+  (def (browser-grab-png view path)
+    (require-view 'browser-grab-png view)
+    (if (= 1 (%grab-png (browser-view-handle view) path))
+        (ok* path)
+        (err* (string-append "grab-png: " (%last-error)))))
+
+  ;; Synthesize a key press+release to the focused widget. qt-key is a Qt::Key,
+  ;; mods a Qt::KeyboardModifiers mask, text the UTF-8 char(s) (or "" for none).
+  (def (browser-send-key view qt-key mods text)
+    (require-view 'browser-send-key view)
+    (if (= 1 (%send-key (browser-view-handle view) qt-key mods text))
+        (ok* #t)
+        (err* (string-append "send-key: " (%last-error)))))
+
   ) ; library (browser)