- 原生 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 前端回归 + 真实时钟调度测试
263 lines
9.7 KiB
Python
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())
|