#!/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" 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())