(eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t) (ql:quickload :usocket :silent t)) (defpackage :passepartout-integration-tests (:use :cl :fiveam :passepartout) (:export #:integration-suite)) (in-package :passepartout-integration-tests) (def-suite integration-suite :description "Integration tests across process boundaries") (in-suite integration-suite) (defvar *daemon-port* nil) (defun find-free-port () (let ((socket (usocket:socket-listen "127.0.0.1" 0 :reuse-address t))) (unwind-protect (usocket:get-local-port socket) (usocket:socket-close socket)))) (defmacro with-daemon (() &body body) `(let ((*daemon-port* (find-free-port))) (unwind-protect (progn (passepartout:actuator-initialize) (passepartout:skill-initialize-all) (passepartout:start-daemon :port *daemon-port*) (sleep 2) ,@body) (handler-case (passepartout:stop-daemon) (error ()))))) (defun daemon-connect () (let* ((sock (usocket:socket-connect "127.0.0.1" *daemon-port*)) (stream (usocket:socket-stream sock))) (read-framed-message stream) ;; discard handshake (values stream sock))) (defun daemon-send (stream msg) (write-string (frame-message msg) stream) (finish-output stream)) (defun daemon-recv (stream &key (timeout 5)) (let ((deadline (+ (get-universal-time) timeout))) (loop (when (listen stream) (return (read-framed-message stream))) (when (> (get-universal-time) deadline) (return nil)) (sleep 0.1)))) (test test-daemon-starts "Contract 1: daemon binds port and sends valid handshake." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (is (open-stream-p stream)) (usocket:socket-close sock)))) (test test-pipeline-user-input "Contract 2: :user-input traverses pipeline and produces a response." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (progn (daemon-send stream '(:TYPE :EVENT :PAYLOAD (:SENSOR :user-input :TEXT "test"))) (let ((resp (daemon-recv stream :timeout 10))) (is (not (null resp)) "Expected a response"))) (usocket:socket-close sock))))) (test test-pipeline-heartbeat "Contract 2: heartbeat signals do not crash the daemon." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (daemon-send stream '(:TYPE :EVENT :PAYLOAD (:SENSOR :heartbeat))) (usocket:socket-close sock)) (pass)))) (test test-tcp-round-trip "Contract 3: framed health-check survives TCP round-trip." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (progn (daemon-send stream '(:TYPE :health-check)) (let ((resp (daemon-recv stream :timeout 5))) (is (not (null resp))) (is (member (getf resp :type) '(:HEALTH-RESPONSE))))) (usocket:socket-close sock))))) (test test-daemon-survives-junk "Contract 3: daemon does not crash on junk input." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (write-string "ZZZZZZ" stream) (finish-output stream) (sleep 1) (usocket:socket-close sock)) ;; Connect again to verify daemon is still alive (multiple-value-bind (stream2 sock2) (daemon-connect) (is (open-stream-p stream2)) (usocket:socket-close sock2)))) (test test-skill-registry-populated "Contract 4: *skill-registry* is populated after daemon start." (with-daemon () (is (hash-table-p passepartout::*skill-registry*)) (is (>= (hash-table-count passepartout::*skill-registry*) 1) "Expected at least 1 skill in registry, got ~a" (hash-table-count passepartout::*skill-registry*)))) (test test-shell-safe-echo "Contract 5: safe shell command does not crash the daemon." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (daemon-send stream '(:TYPE :REQUEST :TARGET :shell :PAYLOAD (:ACTION :execute :CMD "echo hello"))) (usocket:socket-close sock)) (pass)))) (test test-shell-dangerous-blocked "Contract 5: rm -rf / is blocked by the security dispatcher." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (daemon-send stream '(:TYPE :REQUEST :TARGET :shell :PAYLOAD (:ACTION :execute :CMD "rm -rf /"))) (usocket:socket-close sock)) (pass)))) (test test-cli-gateway-input "Contract 6: text via TCP produces a response." (with-daemon () (multiple-value-bind (stream sock) (daemon-connect) (unwind-protect (daemon-send stream '(:TYPE :EVENT :META (:SOURCE :CLI) :PAYLOAD (:SENSOR :user-input :TEXT "hello from CLI"))) (usocket:socket-close sock)) (pass)))) (test test-gateway-registry "Contract 7: gateway-registry-initialize is available." (with-daemon () (is (fboundp 'gateway-registry-initialize)) (gateway-registry-initialize) (pass))) (defun has-api-key (env-var) "Returns T if env-var is set and non-empty." (let ((val (uiop:getenv env-var))) (and val (> (length val) 0)))) (defmacro skip-unless (env-var &body body) "Execute body if env-var is set, otherwise skip the test." `(if (has-api-key ,env-var) (progn ,@body) (progn (format t " [SKIP] ~a not set~%" ,env-var) (skip "~a not set" ,env-var)))) (test test-provider-openai-request "Contract Phase2: provider-openai-request returns :success with valid API key." (skip-unless "OPENROUTER_API_KEY" (let ((result (provider-openai-request "Say hello" "Be brief." :provider :openrouter :model "openrouter/auto"))) (is (or (eq (getf result :status) :success) (eq (getf result :status) :error)) "Expected :success or :error, got: ~a" result)))) (test test-backend-cascade-real "Contract Phase2: backend-cascade-call returns string content with real provider." (skip-unless "OPENROUTER_API_KEY" (let ((passepartout::*provider-cascade* '(:openrouter))) (let ((result (backend-cascade-call "Say hello" :system-prompt "Be brief."))) (is (stringp result) "Expected string response, got: ~a" result))))) (test test-messaging-link "Contract Phase2: messaging-link stores token and gateway-configured-p returns T." (skip-unless "TELEGRAM_BOT_TOKEN" (with-daemon () (messaging-link :telegram :token (uiop:getenv "TELEGRAM_BOT_TOKEN")) (sleep 1) (is (gateway-configured-p :telegram) "Expected telegram to be configured after linking.")))) (test test-flight-plan-message-format "Contract Phase3: dispatcher-flight-plan-create returns valid message." (with-daemon () (load (merge-pathnames ".local/share/passepartout/lisp/security-dispatcher.lisp" (user-homedir-pathname))) (let ((plan (dispatcher-flight-plan-create '(:TYPE :REQUEST :TARGET :shell :PAYLOAD (:CMD "sudo restart"))))) (is (eq :REQUEST (getf plan :type))) (is (eq :emacs (getf plan :target))) (is (eq :insert-node (getf (getf plan :payload) :action))) (let ((attrs (getf (getf plan :payload) :attributes))) (is (string= "Flight Plan: High-Risk Action" (getf attrs :TITLE))) (is (string= "PLAN" (getf attrs :TODO))) (is (member "FLIGHT_PLAN" (getf attrs :TAGS) :test #'string-equal)))))) (test test-emacs-daemon-connect "Contract Phase3: Emacs daemon is reachable via emacsclient." (handler-case (let ((result (uiop:run-program '("emacsclient" "--eval" "(+ 1 2)") :output :string :ignore-error-status t))) (is (search "3" result) "Expected '3' from emacsclient, got: ~a" result)) (error (c) (skip "Emacs daemon not available: ~a" c))))