- 原生 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 前端回归 + 真实时钟调度测试
177 lines
6.0 KiB
JavaScript
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); });
|