/* Verifies cross-device board sync: a "second device" (direct DB write) changes a to-do, and the open desktop tab must pick it up via the 30s poll -- and immediately when the tab regains visibility. */ const { spawn } = require("node:child_process"); const fs = require("node:fs"); const http = require("node:http"); const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; const PORT = 9341; const BASE = "http://localhost:3210"; function httpJson(p) { return new Promise((resolve, reject) => { const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => { let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } }); }); req.on("error", reject); req.end(); }); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function dbTodo(title, completed) { // Simulates the phone: a change that lands in the DB, not this tab. return new Promise((resolve) => { const { execFile } = require("node:child_process"); const sql = `UPDATE "Todo" SET "completed"=${completed}, "completedAt"=${completed ? "now()" : "NULL"} WHERE title='${title}' AND "completed"=${!completed};`; execFile("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", sql], (err, out, errOut) => { resolve({ ok: !err, out, errOut }); }); }); } (async () => { const profile = fs.mkdtempSync("/tmp/edge-sync-"); const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" }); let targets = null; for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} } if (!targets) { console.error("no targets"); process.exit(1); } const page = targets.find((t) => t.type === "page"); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((r) => (ws.onopen = r)); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((resolve) => { const myId = ++id; pending.set(myId, resolve); ws.send(JSON.stringify({ id: myId, method, params })); }); const consoleErrors = []; ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; } if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") { const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" "); if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) consoleErrors.push(txt.slice(0, 200)); } }; const evalJs = (expression) => send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }) .then((r) => (r?.result ? r.result.value : r)); const goto = async (url) => { await send("Page.enable"); await send("Page.navigate", { url }); // Wait for load to settle. for (let i = 0; i < 60; i++) { await sleep(500); const s = await evalJs("document.readyState"); if (s === "complete") return; } throw new Error("page did not load: " + url); }; const checkboxState = (title) => evalJs(`(() => { const el = Array.from(document.querySelectorAll('[data-todo-checkbox]')) .find((el) => (el.getAttribute('aria-label') || '').includes(${JSON.stringify(title)})); return el ? el.getAttribute('aria-checked') : "missing"; })()`); await send("Runtime.enable"); // --- Login --- await goto(`${BASE}/login`); const login = await evalJs(`(async () => { const c = await (await fetch('/api/auth/csrf')).json(); const r = await fetch('/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }), }); return r.status; })()`); console.log("login status:", login); // --- Reset test data before the board loads (idempotent) --- await dbTodo("Milk", false); await dbTodo("Eggs", false); // --- Open the Home board --- await goto(`${BASE}/`); for (let i = 0; i < 60; i++) { await sleep(500); if (await evalJs(`!!document.querySelector('[data-todo-checkbox]')`)) break; } console.log("Milk before:", await checkboxState('Milk')); if ((await checkboxState("Milk")) === "missing") { console.error("FAIL: Milk checkbox not found"); process.exit(1); } // --- Test 1: 30s poll picks up a remote change --- const t0 = Date.now(); console.log("simulating phone: checking off 'Milk' in the DB..."); await dbTodo("Milk", true); let saw = "missing"; for (let i = 0; i < 60; i++) { await sleep(1000); saw = await checkboxState("Milk"); if (saw === "true") break; } const elapsed = Math.round((Date.now() - t0) / 1000); console.log(`Milk after (${elapsed}s):`, saw); const test1 = saw === "true" && elapsed <= 45; console.log(test1 ? "PASS: 30s poll applied the remote change" : "FAIL: poll did not apply the remote change"); // --- Test 2: hidden tab skips, visibilitychange catches up --- await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })`); // Let a poll tick fire while "hidden" (interval is 30s; wait 32s). await sleep(32000); console.log("simulating phone: checking off 'Eggs' in the DB (tab hidden)..."); await dbTodo("Eggs", true); await sleep(2000); const whileHidden = await checkboxState("Eggs"); console.log("Eggs while still hidden:", whileHidden); await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange'))`); // When the sync lands, 'Groceries' becomes fully completed and the card // collapses (its checkboxes disappear from the DOM, replaced by an // "Expand Groceries" disclosure) -- that is exactly how a fully-checked // group renders, so assert the collapsed card rather than the checkbox. let settled = false; for (let i = 0; i < 15; i++) { await sleep(1000); const state = await checkboxState("Eggs"); const collapsed = await evalJs(`!!document.querySelector('[aria-label="Expand Groceries"]')`); if (state === "missing" && collapsed) { settled = true; break; } if (state === "true") { settled = true; break; } } const test2 = settled && whileHidden === "false"; console.log(`Eggs group after tab shown: ${settled ? "synced (card collapsed as fully-complete)" : "NOT synced"}`); console.log(test2 ? "PASS: visibilitychange triggered immediate catch-up" : "FAIL: visibility catch-up"); console.log("console errors:", consoleErrors.length ? consoleErrors : "none"); const ok = test1 && test2 && consoleErrors.length === 0; console.log(ok ? "ALL PASS" : "FAILURES PRESENT"); ws.close(); proc.kill(); process.exit(ok ? 0 : 1); })().catch((e) => { console.error(e); process.exit(1); });