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