Files
fnos-agent-checkin/test_settings_ui.js
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

177 lines
6.0 KiB
JavaScript

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