Organize/.cdp/verify-sync.cjs

154 lines
7.0 KiB
JavaScript

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