154 lines
7.0 KiB
JavaScript
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); });
|