Files
fnos-agent-checkin/test_e2e.py
T

263 lines
9.7 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""End-to-end test: simulate the fnOS install layout and exercise the whole
stack (cmd/main lifecycle, CGI entry, API, daemon scheduling) locally.
Simulated layout (mirrors /var/apps/{appname} on a real device):
/tmp/fnos-sim/var/apps/AgentCheckin/
target/ <- app.tgz contents (+ cmd, config, manifest, icons)
var/ <- @appdata equivalent (persistent data)
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import threading
import time
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer
ROOT = "/vol1/@appshare/fn-deepseek-harness/fnos-agent-checkin"
FPK = os.path.join(ROOT, "AgentCheckin", "AgentCheckin.fpk")
SIM = "/tmp/fnos-sim"
APPDEST = os.path.join(SIM, "var/apps/AgentCheckin")
PKGVAR = os.path.join(SIM, "appdata/AgentCheckin")
TARGET = os.path.join(APPDEST, "target")
CGI_PATH_PREFIX = "/cgi/ThirdParty/AgentCheckin/index.cgi"
PASS, FAIL = [], []
def check(name, cond, extra=""):
(PASS if cond else FAIL).append(name)
print((" PASS " if cond else " FAIL ") + name + ("" if cond else " " + str(extra)[:400]))
def sh(cmd, env=None, **kw):
e = dict(os.environ)
if env:
e.update(env)
return subprocess.run(cmd, shell=True, capture_output=True, text=True, env=e, **kw)
def unpack_fpk():
shutil.rmtree(SIM, ignore_errors=True)
os.makedirs(APPDEST)
os.makedirs(PKGVAR)
tmp = os.path.join(SIM, "pkg")
os.makedirs(tmp)
sh("tar xzf %s -C %s" % (FPK, tmp))
# fnOS installs app.tgz contents into target/, plus cmd/config/manifest/icons
with tarfile.open(os.path.join(tmp, "app.tgz")) as t:
t.extractall(TARGET)
for item in ("cmd", "config", "manifest", "ICON.PNG", "ICON_256.PNG", "wizard"):
src = os.path.join(tmp, item)
dst = os.path.join(APPDEST, item)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy(src, dst)
# make scripts executable as fnOS would
sh("chmod +x %s/target/ui/index.cgi %s/cmd/*" % (APPDEST, APPDEST))
# simulate install_callback
sh("TRIM_PKGVAR=%s bash %s/cmd/install_callback" % (PKGVAR, APPDEST))
ENV_BASE = {
"TRIM_APPDEST": APPDEST,
"TRIM_PKGVAR": PKGVAR,
"TRIM_PKGETC": os.path.join(APPDEST, "etc"),
"AGENTCHECKIN_DEV": "", # ensure TRIM_PKGVAR is used
}
class CGIHandler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _run_cgi(self):
uri = self.path
env = dict(os.environ)
env.update(ENV_BASE)
env["REQUEST_METHOD"] = self.command
env["REQUEST_URI"] = uri
env["QUERY_STRING"] = uri.split("?", 1)[1] if "?" in uri else ""
env["GATEWAY_INTERFACE"] = "CGI/1.1"
env["SERVER_PROTOCOL"] = "HTTP/1.1"
env["REMOTE_ADDR"] = "127.0.0.1"
body = b""
length = int(self.headers.get("Content-Length") or 0)
if length:
body = self.rfile.read(length)
env["CONTENT_LENGTH"] = str(length)
env["CONTENT_TYPE"] = self.headers.get("Content-Type", "")
cgi_file = os.path.join(TARGET, "ui", "index.cgi")
r = subprocess.run(["/bin/bash", cgi_file], input=body,
capture_output=True, env=env)
out = r.stdout
# split CGI headers from body
sep = out.find(b"\r\n\r\n")
if sep < 0:
self.send_response(500)
self.end_headers()
self.wfile.write(out)
return
head = out[:sep].decode("utf-8", "replace")
payload = out[sep + 4:]
status = "200 OK"
headers = {}
for line in head.split("\r\n"):
if not line:
continue
if line.lower().startswith("status:"):
status = line.split(":", 1)[1].strip()
elif ":" in line:
k, v = line.split(":", 1)
headers[k.strip()] = v.strip()
self.send_response(int(status.split()[0]), status.split(" ", 1)[1] if " " in status else "")
for k, v in headers.items():
self.send_header(k, v)
self.end_headers()
try:
self.wfile.write(payload)
except BrokenPipeError:
pass
def do_GET(self):
if self.path.startswith(CGI_PATH_PREFIX):
self._run_cgi()
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
self.do_GET()
def http(method, path, body=None):
url = "http://127.0.0.1:18899" + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def jget(path):
st, raw = http("GET", path)
try:
return st, json.loads(raw.decode("utf-8"))
except ValueError:
return st, {"_raw": raw.decode("utf-8", "replace")[:300]}
def jpost(path, body=None):
st, raw = http("POST", path, body if body is not None else {})
try:
return st, json.loads(raw.decode("utf-8"))
except ValueError:
return st, {"_raw": raw.decode("utf-8", "replace")[:300]}
def main():
print("== unpack fpk into simulated fnOS layout ==")
unpack_fpk()
check("target/index.cgi exists", os.path.isfile(os.path.join(TARGET, "ui", "index.cgi")))
check("target/backend exists", os.path.isdir(os.path.join(TARGET, "backend")))
check("data dir initialized", os.path.isfile(os.path.join(PKGVAR, "accounts.json")))
check("settings initialized", os.path.isfile(os.path.join(PKGVAR, "settings.json")))
print("== cmd/main lifecycle ==")
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
check("status: not running (exit 3)", r.returncode == 3, r.stdout + r.stderr)
r = sh("%s/cmd/main start" % APPDEST, env=ENV_BASE)
check("start: exit 0", r.returncode == 0, r.stdout + r.stderr)
check("daemon pidfile written", os.path.isfile(os.path.join(PKGVAR, "daemon.pid")))
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
check("status: running (exit 0)", r.returncode == 0, r.stdout + r.stderr)
r = sh("%s/cmd/main start" % APPDEST, env=ENV_BASE)
check("start: idempotent", r.returncode == 0 and "already running" in r.stdout, r.stdout)
print("== CGI server ==")
server = HTTPServer(("127.0.0.1", 18899), CGIHandler)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
time.sleep(0.3)
st, d = jget(CGI_PATH_PREFIX + "/")
check("GET / serves index.html", st == 200 and b"Agent" in d.get("_raw", "").encode() or st == 200,
(st, str(d)[:120]))
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/style.css")
check("GET /assets/style.css", st == 200 and b"--primary" in raw, (st, raw[:80]))
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/app.js")
check("GET /assets/app.js", st == 200 and b"AgentCheckin" in raw, (st, raw[:80]))
st, raw = http("GET", CGI_PATH_PREFIX + "/assets/logo.svg")
check("GET /assets/logo.svg", st == 200 and b"<svg" in raw, (st, raw[:80]))
st, raw = http("GET", CGI_PATH_PREFIX + "/../manifest")
check("path traversal blocked", st == 400, (st, raw[:80]))
st, d = jget(CGI_PATH_PREFIX + "/api/state")
check("GET /api/state", st == 200 and d.get("today") is not None, (st, str(d)[:200]))
check("daemon_alive reported true", d.get("daemon_alive") is True, str(d)[:200])
st, d = jpost(CGI_PATH_PREFIX + "/api/accounts/add_trae",
{"name": "E2E TRAE", "access_token": "eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyUmVnaW9uIjp7InJlZ2lvbiI6IkNOIn19.sig"})
check("POST add trae account", st == 200 and d.get("ok") is True, (st, str(d)[:200]))
trae_id = d.get("account", {}).get("id")
st, d = jget(CGI_PATH_PREFIX + "/api/accounts")
check("account token masked", "eyJhbGciOiJSUzI1NiJ9" not in json.dumps(d), str(d)[:200])
st, d = jpost(CGI_PATH_PREFIX + "/api/settings",
{"schedule_times": ["07:30", "21:00"], "webhook_url": "", "notify_on": ["fail"]})
check("POST settings", st == 200 and d["settings"]["schedule_times"] == ["07:30", "21:00"], (st, str(d)[:200]))
st, d = jpost(CGI_PATH_PREFIX + "/api/checkin", {})
check("POST checkin -> trigger mode", st == 200 and d.get("mode") == "trigger", (st, str(d)[:200]))
# daemon should pick up the trigger within ~20s
deadline = time.time() + 40
ran = False
while time.time() < deadline:
st, d = jget(CGI_PATH_PREFIX + "/api/state")
if d.get("today_results"):
ran = True
break
time.sleep(2)
check("daemon executed triggered check-in", ran, str(d)[:300])
if ran:
res = d["today_results"].get(trae_id, {})
check("trae result recorded (no_session expected with fake token)",
res.get("status") in ("no_session", "fail", "ok", "already", "retry_later"),
str(res)[:200])
st, d = jget(CGI_PATH_PREFIX + "/api/logs?lines=50")
check("GET logs", st == 200 and len(d.get("lines", [])) > 0, (st, str(d)[:150]))
print("== cmd/main stop ==")
r = sh("%s/cmd/main stop" % APPDEST, env=ENV_BASE)
check("stop: exit 0", r.returncode == 0, r.stdout + r.stderr)
time.sleep(0.5)
r = sh("%s/cmd/main status" % APPDEST, env=ENV_BASE)
check("status: stopped after stop (exit 3)", r.returncode == 3, r.stdout + r.stderr)
server.shutdown()
print()
print("PASSED: %d FAILED: %d" % (len(PASS), len(FAIL)))
if FAIL:
print("failures:", FAIL)
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())