Stage 0: offline Qt test/snapshot harness + automation FFI
ober
7e808525283e020d321c279b0416f64cf827266c
--- 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 --- 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 --- 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 "" --- 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. --- 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* --- 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 --- 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 new file mode 100644 --- /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" new file mode 100644 --- /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)) --- 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)