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:
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/* Regression test for the settings time-chip race condition.
|
||||
|
||||
Reproduces the exact user-reported bug:
|
||||
1. open the settings tab (local copy seeded from server state)
|
||||
2. the 10s state poll fires and replaces `state` with fresh server data
|
||||
3. the user picks a time and clicks "添加时间点"
|
||||
4. BUG (old code): renderSettings() re-read state.settings -> the new time
|
||||
is silently discarded and the preset times reappear
|
||||
5. FIX: settings is an independent local copy -> the new time survives
|
||||
|
||||
Loads the REAL app/www/assets/app.js with a minimal DOM stub.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------- DOM stub
|
||||
let elSeq = 0;
|
||||
function makeEl(id) {
|
||||
const el = {
|
||||
id: id || ("el" + ++elSeq),
|
||||
_listeners: {},
|
||||
_innerHTML: "",
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
className: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
children: [],
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
|
||||
removeEventListener() {},
|
||||
dispatch(type, ev) { (this._listeners[type] || []).slice().forEach((fn) => fn(ev || { target: this })); },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
querySelector() { return makeEl(); },
|
||||
querySelectorAll() { return []; },
|
||||
select() {},
|
||||
};
|
||||
// mimic the browser: setting innerHTML replaces all children
|
||||
Object.defineProperty(el, "innerHTML", {
|
||||
get() { return el._innerHTML; },
|
||||
set(v) { el._innerHTML = v; if (v === "") el.children.length = 0; },
|
||||
});
|
||||
el.classList = {
|
||||
_s: new Set(),
|
||||
add(c) { this._s.add(c); },
|
||||
remove(c) { this._s.delete(c); },
|
||||
contains(c) { return this._s.has(c); },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const elements = {};
|
||||
function getEl(id) {
|
||||
if (!elements[id]) elements[id] = makeEl(id);
|
||||
return elements[id];
|
||||
}
|
||||
|
||||
const tabButtons = ["dashboard", "accounts", "logs", "settings"].map((t) => {
|
||||
const b = makeEl();
|
||||
b.dataset.tab = t;
|
||||
return b;
|
||||
});
|
||||
|
||||
global.document = {
|
||||
hidden: false,
|
||||
getElementById: getEl,
|
||||
querySelectorAll(sel) {
|
||||
if (sel === ".tab") return tabButtons;
|
||||
return [];
|
||||
},
|
||||
createElement() { return makeEl(); },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- fetch stub
|
||||
let serverSettings = {
|
||||
schedule_times: ["00:05", "09:00", "12:30", "19:00"],
|
||||
webhook_url: "",
|
||||
notify_on: ["fail"],
|
||||
};
|
||||
let postedSettings = null;
|
||||
|
||||
global.fetch = function (url, opts) {
|
||||
opts = opts || {};
|
||||
const method = opts.method || "GET";
|
||||
if (url.indexOf("/settings") >= 0 && method === "POST") {
|
||||
postedSettings = JSON.parse(opts.body);
|
||||
serverSettings = postedSettings;
|
||||
return Promise.resolve({ json: () => Promise.resolve({ ok: true, settings: serverSettings }) });
|
||||
}
|
||||
if (url.indexOf("/state") >= 0) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
accounts: [],
|
||||
settings: serverSettings,
|
||||
today: "2026-10-10",
|
||||
today_results: {},
|
||||
daemon_alive: true,
|
||||
next_run_at: "2026-10-10 09:00:00",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ ok: true }) });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- timers
|
||||
const intervals = [];
|
||||
global.setInterval = (fn) => { intervals.push(fn); return intervals.length; };
|
||||
global.setTimeout = () => 0;
|
||||
global.clearTimeout = () => {};
|
||||
global.clearInterval = () => {};
|
||||
|
||||
// ---------------------------------------------------------------- load app.js
|
||||
const code = fs.readFileSync(
|
||||
path.join(__dirname, "AgentCheckin", "app", "www", "assets", "app.js"), "utf8");
|
||||
eval(code);
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
const chipTimes = () =>
|
||||
getEl("time-chips").children.map((c) => (c.innerHTML || "").replace(/<button.*$/, "").trim());
|
||||
|
||||
(async () => {
|
||||
const results = [];
|
||||
const check = (name, cond, extra) => {
|
||||
results.push([name, cond]);
|
||||
console.log((cond ? " PASS " : " FAIL ") + name + (cond ? "" : " " + String(extra)));
|
||||
};
|
||||
|
||||
await flush(); await flush();
|
||||
|
||||
// 1. open the settings tab
|
||||
tabButtons[3].dispatch("click");
|
||||
check("settings tab shows 4 preset times", JSON.stringify(chipTimes()) === JSON.stringify(["00:05", "09:00", "12:30", "19:00"]), chipTimes());
|
||||
|
||||
// 2. the 10s poll fires (replaces `state` with fresh server data)
|
||||
intervals.forEach((fn) => fn());
|
||||
await flush(); await flush();
|
||||
|
||||
// 3. user picks 21:30 and clicks add
|
||||
getEl("new-time").value = "21:30";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
|
||||
const chips = chipTimes();
|
||||
check("added time 21:30 survives the poll race", chips.indexOf("21:30") >= 0, chips);
|
||||
check("preset times still present", ["00:05", "09:00", "12:30", "19:00"].every((t) => chips.indexOf(t) >= 0), chips);
|
||||
check("exactly 5 chips", chips.length === 5, chips);
|
||||
|
||||
// 4. duplicate add is rejected
|
||||
getEl("new-time").value = "21:30";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
check("duplicate time rejected", chipTimes().length === 5, chipTimes());
|
||||
|
||||
// 5. empty input is rejected
|
||||
getEl("new-time").value = "";
|
||||
getEl("btn-add-time").dispatch("click");
|
||||
check("empty time rejected", chipTimes().length === 5, chipTimes());
|
||||
|
||||
// 6. save persists all 5 times to the server
|
||||
getEl("btn-save-settings").dispatch("click");
|
||||
await flush(); await flush();
|
||||
check("save posts 5 times", postedSettings && postedSettings.schedule_times.length === 5, postedSettings);
|
||||
check("saved times include 21:30", postedSettings && postedSettings.schedule_times.indexOf("21:30") >= 0, postedSettings);
|
||||
|
||||
// 7. leave and re-open the tab: shows the saved state (no stale dirty copy)
|
||||
tabButtons[0].dispatch("click"); // leave settings
|
||||
tabButtons[3].dispatch("click"); // come back
|
||||
check("re-open shows saved 5 times", chipTimes().length === 5, chipTimes());
|
||||
|
||||
const failed = results.filter((r) => !r[1]);
|
||||
console.log("\nPASSED: %d FAILED: %d", results.length - failed.length, failed.length);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
})().catch((e) => { console.error("TEST ERROR:", e); process.exit(1); });
|
||||
Reference in New Issue
Block a user