diff --git a/Makefile b/Makefile index fae5990..6951ffa 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ EMACS ?= emacs -LOAD_PATH = -L . -L examples -L ../ebox -L ../tp -L ../ecss -SOURCES = etaf-view.el etaf-compiler.el etaf-component.el etaf-reactive.el etaf-observer.el etaf-context.el etaf-theme-tp.el etaf-resource.el etaf-data.el etaf-renderer.el etaf-runtime.el etaf-behavior.el etaf-actions.el etaf-events.el etaf-performance.el etaf.el +LOAD_PATH = -L . -L examples -L scripts -L ../ebox -L ../tp -L ../ecss +SOURCES = etaf-view.el etaf-compiler.el etaf-component.el etaf-reactive.el etaf-observer.el etaf-context.el etaf-theme-tp.el etaf-resource.el etaf-data.el etaf-renderer.el etaf-runtime.el etaf-behavior.el etaf-actions.el etaf-events.el etaf-performance.el etaf.el scripts/emacs-gui-verifier.el EXAMPLES = examples/etaf-counter-example.el examples/etaf-data-example.el examples/etaf-resource-example.el -TESTS = tests/etaf-tests.el tests/etaf-compiler-tests.el tests/etaf-resource-tests.el tests/etaf-data-tests.el tests/etaf-theme-tp-tests.el tests/etaf-examples-tests.el tests/etaf-observer-tests.el tests/etaf-performance-tests.el +TESTS = tests/etaf-tests.el tests/etaf-compiler-tests.el tests/etaf-resource-tests.el tests/etaf-data-tests.el tests/etaf-theme-tp-tests.el tests/etaf-examples-tests.el tests/etaf-observer-tests.el tests/etaf-performance-tests.el tests/etaf-gui-verifier-tests.el .PHONY: test compile load checkdoc docs-check check clean @@ -13,7 +13,7 @@ test: compile compile: clean $(EMACS) -Q --batch $(LOAD_PATH) \ - --eval "(setq load-prefer-newer t)" \ + --eval "(setq load-prefer-newer t byte-compile-error-on-warn t byte-compile-warnings '(not obsolete))" \ --eval "(load-file \"etaf.el\")" \ --eval "(dolist (file '($(foreach file,$(SOURCES) $(EXAMPLES),\"$(file)\"))) (byte-compile-file file))" @@ -25,9 +25,9 @@ docs-check: -f ert-run-tests-batch-and-exit checkdoc: - $(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (directory (list "." "examples")) (dolist (file (directory-files directory t)) (when (string-suffix-p ".el" file) (checkdoc-file file)))))' + $(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (directory (list "." "examples" "scripts")) (dolist (file (directory-files directory t)) (when (string-suffix-p ".el" file) (checkdoc-file file)))))' check: checkdoc compile test docs-check clean: - rm -f *.elc examples/*.elc tests/*.elc + rm -f *.elc examples/*.elc scripts/*.elc tests/*.elc diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..4a4b2fa --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,31 @@ +# Generic Emacs GUI verification + +This directory owns the reusable verification mechanism, not application +scenarios: + +- `emacs-gui-verifier.el` defines `Scenario`, ordered `Action`, run-local + `Context`, checkpoint sequencing, assertions, completion, and fail-closed + evidence finalization. +- `run-emacs-gui-verification.sh` owns one named daemon, explicit load paths, + external application activation, recorder lifecycle, driver loading, report + generation, and exact cleanup. +- `record-screen.exp` keeps macOS `screencapture -v` attached to a PTY and stops + it through an explicit signal. It contains no Emacs or application logic. + +Concrete repositories provide adapter files that construct a Scenario and an +entry function. The generic command is: + +```sh +scripts/run-emacs-gui-verification.sh run ADAPTER.el ENTRY \ + --load-path /path/to/provider \ + --run-dir /private/tmp/my-gui-run +``` + +Fresh captures intentionally remain `INCOMPLETE` until their selected images +and contact sheet are reviewed. Finalize the same evidence directory with: + +```sh +scripts/run-emacs-gui-verification.sh review /private/tmp/my-gui-run +``` + +Only `VERDICT=PASS` is completion evidence. diff --git a/scripts/emacs-gui-verifier.el b/scripts/emacs-gui-verifier.el new file mode 100644 index 0000000..aaf3476 --- /dev/null +++ b/scripts/emacs-gui-verifier.el @@ -0,0 +1,198 @@ +;;; emacs-gui-verifier.el --- Generic temporal GUI scenario engine -*- lexical-binding: t; -*- + +;;; Commentary: + +;; This developer tool executes declarative GUI scenarios against any Emacs +;; buffer. It owns action ordering, checkpoint phases, target-buffer guards, +;; completion, and evidence lifecycle. It does not know ETAF Runtime, Ebox, +;; Playground, or any concrete example; adapters supply those facts through +;; callbacks and opaque context data. + +;;; Code: + +(require 'cl-lib) + +(declare-function emacs-dynamic-ui-verification-start + "capture-checkpoint" (directory run-id claim required-phases)) +(declare-function emacs-dynamic-ui-verification-checkpoint + "capture-checkpoint" + (action-id phase adapter assertions screenshot)) +(declare-function emacs-dynamic-ui-verification-finish + "capture-checkpoint" (completed adapter)) + +(cl-defstruct (etaf-gui-verifier-action + (:constructor etaf-gui-verifier-action-create)) + "One ordered GUI action and its postcondition callback." + id execute assertions screenshot settled-p + (settle-timeout 5.0) (settle-interval 0.05)) + +(cl-defstruct (etaf-gui-verifier-scenario + (:constructor etaf-gui-verifier-scenario-create)) + "One reusable GUI scenario assembled by an adapter." + name claim initialize actions invariants adapter completion) + +(cl-defstruct (etaf-gui-verifier-context + (:constructor etaf-gui-verifier--context-create)) + "Mutable execution state owned only by one verifier run." + scenario target-buffer data (action-count 0) (last-duration-ms 0.0)) + +(defun etaf-gui-verifier-context-put (context key value) + "Store adapter VALUE for KEY in CONTEXT and return VALUE." + (setf (alist-get key (etaf-gui-verifier-context-data context)) value) + value) + +(defun etaf-gui-verifier-context-get (context key &optional default) + "Return CONTEXT adapter value KEY, or DEFAULT when absent." + (alist-get key (etaf-gui-verifier-context-data context) default)) + +(defun etaf-gui-verifier-context-select-buffer (context buffer) + "Select live BUFFER as CONTEXT's single-window target." + (unless (buffer-live-p buffer) + (error "GUI verifier target buffer is not live")) + (setf (etaf-gui-verifier-context-target-buffer context) buffer) + (switch-to-buffer buffer) + (delete-other-windows) + buffer) + +(defun etaf-gui-verifier-assert (name passed &optional detail) + "Return one normalized assertion NAME for PASSED and optional DETAIL." + (append (list (cons 'name name) (cons 'passed (and passed t))) + (when detail (list (cons 'detail detail))))) + +(defun etaf-gui-verifier--target-assertions (context) + "Return generic selected-target assertions for CONTEXT." + (let ((buffer (etaf-gui-verifier-context-target-buffer context))) + (list + (etaf-gui-verifier-assert + "target-buffer-selected" + (and (buffer-live-p buffer) + (eq (window-buffer (selected-window)) buffer)))))) + +(defun etaf-gui-verifier--scenario-assertions (context) + "Return generic and adapter assertions for CONTEXT." + (let* ((scenario (etaf-gui-verifier-context-scenario context)) + (function (etaf-gui-verifier-scenario-invariants scenario))) + (append + (etaf-gui-verifier--target-assertions context) + (and function (funcall function context))))) + +(defun etaf-gui-verifier--adapter-data (context) + "Return generic and adapter JSON data for CONTEXT." + (let* ((scenario (etaf-gui-verifier-context-scenario context)) + (function (etaf-gui-verifier-scenario-adapter scenario))) + (append + (list + (cons 'scenario (etaf-gui-verifier-scenario-name scenario)) + (cons 'action_count + (etaf-gui-verifier-context-action-count context)) + (cons 'duration_ms + (etaf-gui-verifier-context-last-duration-ms context))) + (and function (funcall function context))))) + +(defun etaf-gui-verifier--checkpoint + (context action-id phase screenshot &optional extra) + "Capture CONTEXT ACTION-ID PHASE with SCREENSHOT and EXTRA assertions." + (emacs-dynamic-ui-verification-checkpoint + action-id phase + (etaf-gui-verifier--adapter-data context) + (append (etaf-gui-verifier--scenario-assertions context) extra) + screenshot)) + +(defun etaf-gui-verifier--settle-action (context action) + "Wait until ACTION's visible result is settled in CONTEXT." + (let ((settled-p (etaf-gui-verifier-action-settled-p action)) + (timeout (etaf-gui-verifier-action-settle-timeout action)) + (interval (etaf-gui-verifier-action-settle-interval action))) + (unless settled-p + (error "GUI action has no settle predicate: %s" + (etaf-gui-verifier-action-id action))) + (unless (and (numberp timeout) (> timeout 0)) + (error "GUI action settle timeout must be positive: %s" + (etaf-gui-verifier-action-id action))) + (unless (and (numberp interval) (> interval 0)) + (error "GUI action settle interval must be positive: %s" + (etaf-gui-verifier-action-id action))) + ;; Always complete at least one redisplay before accepting a predicate. + ;; A predicate proves adapter state, not that Emacs painted that state. + (redisplay t) + (sit-for 0) + (let ((deadline + (+ (float-time) timeout)) + settled) + (while (not settled) + (when (>= (float-time) deadline) + (error "GUI action did not settle before timeout: %s" + (etaf-gui-verifier-action-id action))) + (if (funcall settled-p context) + ;; Require the same postcondition across one event/paint turn. + ;; Runtime state can be synchronous while the GUI compositor still + ;; presents the preceding frame. + (progn + (sit-for interval) + (redisplay t) + (setq settled (funcall settled-p context))) + (sit-for interval) + (redisplay t)))))) + +(defun etaf-gui-verifier--run-action (context action) + "Execute ACTION once inside CONTEXT's ordered checkpoint protocol." + (let ((action-id (etaf-gui-verifier-action-id action)) + (assertions (etaf-gui-verifier-action-assertions action))) + (etaf-gui-verifier--checkpoint + context action-id "before-action" nil) + (let ((started (float-time))) + (funcall (etaf-gui-verifier-action-execute action) context) + (setf (etaf-gui-verifier-context-last-duration-ms context) + (* 1000.0 (- (float-time) started)))) + (cl-incf (etaf-gui-verifier-context-action-count context)) + (etaf-gui-verifier--checkpoint + context action-id "after-action" nil) + (raise-frame) + (etaf-gui-verifier--settle-action context action) + (etaf-gui-verifier--checkpoint + context action-id "after-redisplay" + (etaf-gui-verifier-action-screenshot action) + (and assertions (funcall assertions context))))) + +;;;###autoload +(defun etaf-gui-verifier-run (scenario run-directory) + "Execute generic SCENARIO and write temporal evidence to RUN-DIRECTORY." + (unless (etaf-gui-verifier-scenario-p scenario) + (signal 'wrong-type-argument + (list 'etaf-gui-verifier-scenario-p scenario))) + (let* ((context + (etaf-gui-verifier--context-create :scenario scenario)) + (initialize (etaf-gui-verifier-scenario-initialize scenario)) + (completion (etaf-gui-verifier-scenario-completion scenario)) + finished-p) + (emacs-dynamic-ui-verification-start + run-directory + (etaf-gui-verifier-scenario-name scenario) + (etaf-gui-verifier-scenario-claim scenario) + '("before-action" "after-action" "after-redisplay")) + (condition-case error-data + (progn + (when initialize (funcall initialize context)) + (dolist (action (etaf-gui-verifier-scenario-actions scenario)) + (etaf-gui-verifier--run-action context action)) + (let ((completed (if completion + (funcall completion context) + t))) + (emacs-dynamic-ui-verification-finish + completed (etaf-gui-verifier--adapter-data context)) + (setq finished-p t) + (unless completed + (error "GUI scenario completion predicate failed"))) + context) + ((error quit) + (unless finished-p + (condition-case finish-error + (emacs-dynamic-ui-verification-finish + nil (etaf-gui-verifier--adapter-data context)) + (error + (message "GUI verifier could not record failed completion: %s" + (error-message-string finish-error))))) + (signal (car error-data) (cdr error-data)))))) + +(provide 'emacs-gui-verifier) +;;; emacs-gui-verifier.el ends here diff --git a/scripts/record-screen.exp b/scripts/record-screen.exp new file mode 100755 index 0000000..e95eddf --- /dev/null +++ b/scripts/record-screen.exp @@ -0,0 +1,31 @@ +#!/usr/bin/expect -f + +# Keep macOS screencapture attached to a real PTY. Redirecting its stdin to +# /dev/null makes current macOS builds stop recording immediately. + +set maximum_seconds 600 + +if {$argc != 1} { + puts stderr "usage: record-screen.exp OUTPUT" + exit 2 +} + +set output [lindex $argv 0] +spawn -noecho /usr/sbin/screencapture -v -D 1 -x $output +set deadline [expr {[clock seconds] + $maximum_seconds}] + +trap { + send -- "q" + expect eof + exit 0 +} {SIGTERM SIGINT} + +while {1} { + after 1000 + if {[clock seconds] >= $deadline} { + send -- "q" + expect eof + puts stderr "screen recording exceeded $maximum_seconds seconds" + exit 1 + } +} diff --git a/scripts/run-emacs-gui-verification.sh b/scripts/run-emacs-gui-verification.sh new file mode 100755 index 0000000..80e5194 --- /dev/null +++ b/scripts/run-emacs-gui-verification.sh @@ -0,0 +1,330 @@ +#!/bin/sh + +set -eu + +GUI_CORE_SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) +GUI_ENGINE="$GUI_CORE_SCRIPT_DIR/emacs-gui-verifier.el" +GUI_RECORDER="$GUI_CORE_SCRIPT_DIR/record-screen.exp" +GUI_EMACS_APP=${EMACS_APP:-/Applications/Emacs.app} +GUI_EMACS_BIN="$GUI_EMACS_APP/Contents/MacOS/Emacs" +GUI_EMACSCLIENT="$GUI_EMACS_APP/Contents/MacOS/bin/emacsclient" +GUI_EMACS_APP_NAME=${EMACS_APP_NAME:-Emacs} +GUI_CODEX_ROOT=${CODEX_HOME:-${HOME}/.codex} +GUI_DYNAMIC_SKILL=${EMACS_DYNAMIC_UI_SKILL_DIR:-$GUI_CODEX_ROOT/skills/emacs-dynamic-ui-verification} +GUI_CHECKPOINT_EL="$GUI_DYNAMIC_SKILL/scripts/capture-checkpoint.el" +GUI_EVIDENCE_PY="$GUI_DYNAMIC_SKILL/scripts/evidence.py" + +usage() { + echo "usage:" >&2 + echo " $0 doctor" >&2 + echo " $0 review RUN_DIR" >&2 + echo " $0 run ADAPTER_EL ENTRY_FUNCTION [--run-dir DIR] [--load-path DIR]..." >&2 + exit 2 +} + +require_file() { + [ -f "$1" ] || { + echo "required file is missing: $1" >&2 + exit 1 + } +} + +doctor() { + require_file "$GUI_EMACS_BIN" + require_file "$GUI_EMACSCLIENT" + require_file "$GUI_ENGINE" + require_file "$GUI_RECORDER" + require_file "$GUI_CHECKPOINT_EL" + require_file "$GUI_EVIDENCE_PY" + command -v osascript >/dev/null 2>&1 + command -v expect >/dev/null 2>&1 + command -v ffmpeg >/dev/null 2>&1 + command -v python3 >/dev/null 2>&1 + echo "EMACS-GUI-VERIFIER DOCTOR PASS" +} + +review_run() { + [ "$#" -eq 1 ] || usage + GUI_REVIEW_DIR=$1 + require_file "$GUI_REVIEW_DIR/manifest.jsonl" + require_file "$GUI_REVIEW_DIR/recording.mov" + python3 "$GUI_EVIDENCE_PY" finalize \ + --run-dir "$GUI_REVIEW_DIR" --temporal-reviewed +} + +GUI_DAEMON="" +GUI_DAEMON_PID="" +GUI_RECORDER_PID="" +GUI_RECORDER_CHILD_PID="" +GUI_DAEMON_STARTED=false + +valid_pid() { + case $1 in + ''|*[!0-9]*) return 1 ;; + *) [ "$1" -gt 1 ] ;; + esac +} + +process_alive() { + valid_pid "$1" && kill -0 "$1" 2>/dev/null +} + +wait_for_exit() { + GUI_WAIT_PID=$1 + GUI_WAIT_LIMIT=$2 + GUI_WAIT_ATTEMPT=0 + while process_alive "$GUI_WAIT_PID"; do + [ "$GUI_WAIT_ATTEMPT" -lt "$GUI_WAIT_LIMIT" ] || return 1 + sleep 0.1 + GUI_WAIT_ATTEMPT=$((GUI_WAIT_ATTEMPT + 1)) + done +} + +force_owned_exit() { + GUI_FORCE_PID=$1 + GUI_FORCE_LABEL=$2 + process_alive "$GUI_FORCE_PID" || return 0 + echo "forcing owned $GUI_FORCE_LABEL process to exit: $GUI_FORCE_PID" >&2 + kill -TERM "$GUI_FORCE_PID" 2>/dev/null || return 1 + if ! wait_for_exit "$GUI_FORCE_PID" 20; then + kill -KILL "$GUI_FORCE_PID" 2>/dev/null || return 1 + wait_for_exit "$GUI_FORCE_PID" 10 || return 1 + fi + return 0 +} + +stop_recorder() { + [ -n "$GUI_RECORDER_PID" ] || return 0 + GUI_RECORDER_STOP_FAILED=0 + if process_alive "$GUI_RECORDER_PID"; then + if ! kill -TERM "$GUI_RECORDER_PID"; then + GUI_RECORDER_STOP_FAILED=1 + elif ! wait_for_exit "$GUI_RECORDER_PID" 50; then + GUI_RECORDER_STOP_FAILED=1 + if ! force_owned_exit "$GUI_RECORDER_PID" "recorder wrapper"; then + GUI_RECORDER_STOP_FAILED=1 + fi + fi + fi + if process_alive "$GUI_RECORDER_CHILD_PID"; then + GUI_RECORDER_STOP_FAILED=1 + if ! force_owned_exit "$GUI_RECORDER_CHILD_PID" "screen recorder"; then + GUI_RECORDER_STOP_FAILED=1 + fi + fi + if ! wait "$GUI_RECORDER_PID" 2>/dev/null; then + GUI_RECORDER_STOP_FAILED=1 + fi + GUI_RECORDER_PID="" + GUI_RECORDER_CHILD_PID="" + [ "$GUI_RECORDER_STOP_FAILED" -eq 0 ] +} + +stop_daemon() { + [ "$GUI_DAEMON_STARTED" = true ] || return 0 + GUI_DAEMON_STOP_FAILED=0 + if ! "$GUI_EMACSCLIENT" -n -s "$GUI_DAEMON" -e '(kill-emacs 0)' \ + >"$GUI_RUN_DIR/daemon-shutdown.log" 2>&1; then + GUI_DAEMON_STOP_FAILED=1 + fi + if ! wait_for_exit "$GUI_DAEMON_PID" 50; then + GUI_DAEMON_STOP_FAILED=1 + if ! force_owned_exit "$GUI_DAEMON_PID" "Emacs daemon"; then + GUI_DAEMON_STOP_FAILED=1 + fi + fi + GUI_DAEMON_STARTED=false + GUI_DAEMON_PID="" + GUI_DAEMON="" + [ "$GUI_DAEMON_STOP_FAILED" -eq 0 ] +} + +cleanup() { + GUI_CLEANUP_FAILED=0 + if ! stop_recorder; then + echo "GUI recorder cleanup failed" >&2 + GUI_CLEANUP_FAILED=1 + fi + if ! stop_daemon; then + echo "GUI daemon cleanup failed" >&2 + GUI_CLEANUP_FAILED=1 + fi + [ "$GUI_CLEANUP_FAILED" -eq 0 ] +} + +on_exit() { + GUI_MAIN_STATUS=$? + trap - EXIT HUP INT TERM + if ! cleanup; then + [ "$GUI_MAIN_STATUS" -ne 0 ] || GUI_MAIN_STATUS=1 + fi + exit "$GUI_MAIN_STATUS" +} + +trap on_exit EXIT +trap 'exit 130' HUP INT TERM + +activate_emacs() { + osascript -e "tell application \"$GUI_EMACS_APP_NAME\" to activate" + GUI_ACTIVATE_ATTEMPTS=0 + while [ "$GUI_ACTIVATE_ATTEMPTS" -lt 30 ]; do + GUI_FRONTMOST=$(osascript -e \ + 'tell application "System Events" to get name of first application process whose frontmost is true') + case $GUI_FRONTMOST in + *Emacs*) + sleep 0.2 + return 0 + ;; + esac + sleep 0.1 + GUI_ACTIVATE_ATTEMPTS=$((GUI_ACTIVATE_ATTEMPTS + 1)) + done + echo "Emacs did not become the frontmost application" >&2 + exit 1 +} + +start_recorder() { + /usr/bin/expect "$GUI_RECORDER" "$GUI_RUN_DIR/recording.mov" \ + >"$GUI_RUN_DIR/recorder.log" 2>&1 & + GUI_RECORDER_PID=$! + GUI_RECORDER_ATTEMPTS=0 + while [ "$GUI_RECORDER_ATTEMPTS" -lt 30 ]; do + if ! kill -0 "$GUI_RECORDER_PID" 2>/dev/null; then + echo "screen recorder exited before the scenario started" >&2 + exit 1 + fi + GUI_RECORDER_CHILD_PID=$( + pgrep -P "$GUI_RECORDER_PID" -x screencapture 2>/dev/null | + sed -n '1p' + ) + if process_alive "$GUI_RECORDER_CHILD_PID"; then + return 0 + fi + sleep 0.1 + GUI_RECORDER_ATTEMPTS=$((GUI_RECORDER_ATTEMPTS + 1)) + done + echo "screen recorder did not create its PTY child" >&2 + exit 1 +} + +parse_run_arguments() { + [ "$#" -ge 2 ] || usage + GUI_ADAPTER=$1 + GUI_ENTRY=$2 + shift 2 + GUI_RUN_DIR="" + GUI_LOAD_PATHS="" + while [ "$#" -gt 0 ]; do + case $1 in + --run-dir) + [ "$#" -ge 2 ] || usage + GUI_RUN_DIR=$2 + shift 2 + ;; + --load-path) + [ "$#" -ge 2 ] || usage + case $2 in *:*) + echo "load path cannot contain a colon: $2" >&2 + exit 1 + ;; + esac + if [ -n "$GUI_LOAD_PATHS" ]; then + GUI_LOAD_PATHS="$GUI_LOAD_PATHS:$2" + else + GUI_LOAD_PATHS=$2 + fi + shift 2 + ;; + *) usage ;; + esac + done + require_file "$GUI_ADAPTER" + if [ -z "$GUI_RUN_DIR" ]; then + GUI_RUN_DIR=$(mktemp -d /private/tmp/emacs-gui-verification.XXXXXX) + else + mkdir -p "$GUI_RUN_DIR" + fi + if [ -e "$GUI_RUN_DIR/manifest.jsonl" ] || [ -e "$GUI_RUN_DIR/recording.mov" ]; then + echo "run directory already contains GUI evidence: $GUI_RUN_DIR" >&2 + exit 1 + fi +} + +run_adapter() { + doctor >/dev/null + export ETAF_GUI_RUN_DIR="$GUI_RUN_DIR" + export ETAF_GUI_LOAD_PATHS="$GUI_LOAD_PATHS" + export ETAF_GUI_ENTRY="$GUI_ENTRY" + GUI_DAEMON="emacs-gui-verify-$$" + "$GUI_EMACS_BIN" -Q --daemon="$GUI_DAEMON" \ + --eval '(setq native-comp-jit-compilation nil load-prefer-newer t)' + GUI_DAEMON_STARTED=true + GUI_DAEMON_PID=$( + "$GUI_EMACSCLIENT" -n -s "$GUI_DAEMON" -e '(emacs-pid)' + ) + valid_pid "$GUI_DAEMON_PID" || { + echo "Emacs daemon returned an invalid process id: $GUI_DAEMON_PID" >&2 + exit 1 + } + "$GUI_EMACSCLIENT" -n -s "$GUI_DAEMON" -c -e \ + "(progn + (dolist (path (split-string (or (getenv \"ETAF_GUI_LOAD_PATHS\") \"\") path-separator t)) + (add-to-list 'load-path path)) + (load \"$GUI_CHECKPOINT_EL\" nil nil t) + (load \"$GUI_ENGINE\" nil nil t) + (load \"$GUI_ADAPTER\" nil nil t) + (when (fboundp 'tool-bar-mode) (tool-bar-mode -1)) + (when (fboundp 'menu-bar-mode) (menu-bar-mode -1)) + (when (fboundp 'scroll-bar-mode) (scroll-bar-mode -1)) + (set-frame-parameter nil 'fullscreen 'maximized) + (raise-frame) + (redisplay t) + t)" >"$GUI_RUN_DIR/bootstrap.out" + activate_emacs + start_recorder + "$GUI_EMACSCLIENT" -n -s "$GUI_DAEMON" -e \ + '(funcall (intern (getenv "ETAF_GUI_ENTRY")))' \ + >"$GUI_RUN_DIR/scenario.out" + stop_recorder + require_file "$GUI_RUN_DIR/recording.mov" + [ -s "$GUI_RUN_DIR/recording.mov" ] || { + echo "screen recording is empty" >&2 + exit 1 + } + stop_daemon + + set +e + python3 "$GUI_EVIDENCE_PY" finalize --run-dir "$GUI_RUN_DIR" \ + >"$GUI_RUN_DIR/finalize.out" 2>&1 + GUI_FINALIZE_STATUS=$? + set -e + cat "$GUI_RUN_DIR/finalize.out" + if [ "$GUI_FINALIZE_STATUS" -eq 1 ]; then + echo "GUI verification assertions failed: $GUI_RUN_DIR" >&2 + exit 1 + fi + if [ "$GUI_FINALIZE_STATUS" -ne 2 ]; then + echo "fresh capture unexpectedly bypassed temporal review" >&2 + exit 1 + fi + echo "EMACS-GUI-VERIFIER CAPTURE COMPLETE verdict=INCOMPLETE entry=$GUI_ENTRY run-dir=$GUI_RUN_DIR" +} + +[ "$#" -ge 1 ] || usage +GUI_COMMAND=$1 +shift + +case $GUI_COMMAND in + doctor) + [ "$#" -eq 0 ] || usage + doctor + ;; + review) + review_run "$@" + ;; + run) + parse_run_arguments "$@" + run_adapter + ;; + *) usage ;; +esac diff --git a/tests/etaf-gui-verifier-tests.el b/tests/etaf-gui-verifier-tests.el new file mode 100644 index 0000000..18c321a --- /dev/null +++ b/tests/etaf-gui-verifier-tests.el @@ -0,0 +1,146 @@ +;;; etaf-gui-verifier-tests.el --- Generic GUI scenario engine tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'emacs-gui-verifier) + +(ert-deftest etaf-gui-verifier-composes-generic-scenario-actions () + "The engine should own ordering while adapters own actions and assertions." + (let ((buffer (generate-new-buffer " *etaf-gui-verifier-test*")) + events checkpoints finished (settle-checks 0)) + (unwind-protect + (cl-letf + (((symbol-function 'emacs-dynamic-ui-verification-start) + (lambda (_directory run-id _claim phases) + (push (list 'start run-id phases) events))) + ((symbol-function 'emacs-dynamic-ui-verification-checkpoint) + (lambda (action phase adapter assertions screenshot) + (push (list action phase adapter assertions screenshot) + checkpoints))) + ((symbol-function 'emacs-dynamic-ui-verification-finish) + (lambda (completed adapter) + (setq finished (list completed adapter))))) + (let* ((scenario + (etaf-gui-verifier-scenario-create + :name "generic" + :claim "generic claim" + :initialize + (lambda (context) + (etaf-gui-verifier-context-select-buffer context buffer)) + :invariants + (lambda (context) + (list + (etaf-gui-verifier-assert + "buffer-live" + (buffer-live-p + (etaf-gui-verifier-context-target-buffer context))))) + :adapter + (lambda (context) + (list + (cons 'size + (with-current-buffer + (etaf-gui-verifier-context-target-buffer context) + (buffer-size))))) + :actions + (list + (etaf-gui-verifier-action-create + :id "insert-a" :settle-interval 0.001 + :execute + (lambda (context) + (with-current-buffer + (etaf-gui-verifier-context-target-buffer context) + (insert "A"))) + :settled-p + (lambda (_context) + (>= (cl-incf settle-checks) 2)) + :assertions + (lambda (context) + (list + (etaf-gui-verifier-assert + "one-character" + (= 1 + (with-current-buffer + (etaf-gui-verifier-context-target-buffer context) + (buffer-size)))))) + :screenshot t) + (etaf-gui-verifier-action-create + :id "insert-b" + :execute + (lambda (context) + (with-current-buffer + (etaf-gui-verifier-context-target-buffer context) + (insert "B"))) + :settled-p (lambda (_context) t))) + :completion + (lambda (context) + (= 2 (etaf-gui-verifier-context-action-count context))))) + (context (etaf-gui-verifier-run scenario "/tmp/generic"))) + (should (= 2 (etaf-gui-verifier-context-action-count context))) + (should (equal "AB" (with-current-buffer buffer (buffer-string)))) + (should (car finished)) + (should (= settle-checks 3)) + (should (= 6 (length checkpoints))) + (should + (equal + '("before-action" "after-action" "after-redisplay" + "before-action" "after-action" "after-redisplay") + (mapcar #'cadr (nreverse checkpoints)))))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest etaf-gui-verifier-rejects-invalid-settle-contracts () + "Every action should have a bounded, non-busy settle contract." + (let ((context (etaf-gui-verifier--context-create))) + (dolist + (action + (list + (etaf-gui-verifier-action-create :id "missing") + (etaf-gui-verifier-action-create + :id "timeout" :settled-p (lambda (_context) t) + :settle-timeout 0) + (etaf-gui-verifier-action-create + :id "interval" :settled-p (lambda (_context) t) + :settle-interval 0))) + (should-error (etaf-gui-verifier--settle-action context action))))) + +(ert-deftest etaf-gui-verifier-timeout-finishes-false-and-propagates () + "A settle timeout should finish incomplete and preserve its error." + (let ((buffer (generate-new-buffer " *etaf-gui-timeout-test*")) + finished) + (unwind-protect + (cl-letf + (((symbol-function 'emacs-dynamic-ui-verification-start) + (lambda (&rest _arguments) nil)) + ((symbol-function 'emacs-dynamic-ui-verification-checkpoint) + (lambda (&rest _arguments) nil)) + ((symbol-function 'emacs-dynamic-ui-verification-finish) + (lambda (completed adapter) + (setq finished (list completed adapter))))) + (let* ((scenario + (etaf-gui-verifier-scenario-create + :name "timeout" + :claim "timeout must fail closed" + :initialize + (lambda (context) + (etaf-gui-verifier-context-select-buffer context buffer)) + :actions + (list + (etaf-gui-verifier-action-create + :id "never-settles" + :execute (lambda (_context) nil) + :settled-p (lambda (_context) nil) + :settle-timeout 0.003 + :settle-interval 0.001)))) + (error-data + (should-error + (etaf-gui-verifier-run scenario "/tmp/timeout")))) + (should + (string-match-p + "did not settle" (error-message-string error-data))) + (should finished) + (should-not (car finished)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(provide 'etaf-gui-verifier-tests) +;;; etaf-gui-verifier-tests.el ends here