Files
fnos-agent-checkin/test_e2e.py
T
cbb a3b84d5ce2 feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到
- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时
- 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits
- WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头
- 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认
- 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别)
- Qoder 双站点 Token 隔离自动探测与修复
- 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停
- 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
2026-10-13 19:09:48 +08:00

263 lines
9.7 KiB
Python

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