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 前端回归 + 真实时钟调度测试
This commit is contained in:
+262
@@ -0,0 +1,262 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user