// End-to-end check of the Timeline layout in a real browser. // Usage (tracker must be running): node e2e/timeline-check.mjs // Checks that the page uses the full width, the chart reaches the right edge, and the border between labels and chart can be dragged (header and // row border), moved with the keyboard, and is remembered after a reload. import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; const [chrome, base, outDir] = process.argv.slice(2); if (!chrome || !base || !outDir) { console.error("usage: node e2e/timeline-check.mjs "); process.exit(2); } const W = 1600, H = 1000; const proc = spawn(chrome, ["--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--remote-debugging-port=9335", `--window-size=${W},${H}`, "about:blank"], { stdio: "ignore" }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ws, id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); const ev = async (e) => (await send("Runtime.evaluate", { expression: e, returnByValue: true })).result.value; const shot = async (name) => { const r = await send("Page.captureScreenshot", { format: "png" }); writeFileSync(`${outDir}/${name}.png`, Buffer.from(r.data, "base64")); }; const go = async (path) => { await send("Page.navigate", { url: base + path }); await sleep(1200); }; const mouse = (type, x, y) => send("Input.dispatchMouseEvent", { type, x, y, button: "left", buttons: type === "mouseReleased" ? 0 : 1, clickCount: 1 }); const drag = async (x, y, dx) => { await mouse("mouseMoved", x, y); await mouse("mousePressed", x, y); for (let i = 1; i <= 5; i++) await mouse("mouseMoved", x + (dx * i) / 5, y); await mouse("mouseReleased", x + dx, y); await sleep(150); }; const labelW = () => ev(`document.querySelector(".tl-corner").offsetWidth`); let failed = 0; const check = (ok, msg) => { console.log(`${ok ? "PASS" : "FAIL"} ${msg}`); if (!ok) failed++; }; try { let t; for (let i = 0; i < 50 && !t; i++) { try { t = (await (await fetch("http://127.0.0.1:9335/json")).json()).find((x) => x.type === "page"); } catch {} await sleep(200); } ws = new WebSocket(t.webSocketDebuggerUrl); await new Promise((r) => ws.addEventListener("open", r)); ws.addEventListener("message", (m) => { const d = JSON.parse(m.data); if (pending.has(d.id)) { pending.get(d.id)(d.result); pending.delete(d.id); } }); await send("Emulation.setDeviceMetricsOverride", { width: W, height: H, deviceScaleFactor: 1, mobile: false }); await go("/timeline?scale=week"); await ev(`localStorage.removeItem("tracker-timeline-label-width")`); await go("/timeline?scale=week"); const m = await ev(`(() => { const main = document.querySelector(".main"), side = document.querySelector(".side"), tl = document.getElementById("timeline"); return { main: main.offsetWidth, side: side.offsetWidth, chart: document.querySelector(".tl-axis").offsetWidth + document.querySelector(".tl-corner").offsetWidth, client: tl.clientWidth }; })()`); check(m.main + m.side >= W - 2, `main uses the full width (main ${m.main} + menu ${m.side} of ${W})`); check(m.chart >= m.client - 1, `the chart reaches the right edge with no empty space (${m.chart} vs ${m.client})`); await shot("timeline-wide"); // Fold an epic: its task rows disappear, unfold brings them back. const visibleTasks = () => ev(`[...document.querySelectorAll("#timeline .tl-task")].filter((r) => r.offsetHeight > 0).length`); const before = await visibleTasks(); const tg = await ev(`(() => { const b = document.querySelector("#timeline .tl-epic [data-tl-toggle]"); const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, n: document.querySelectorAll(\`#timeline [data-parent="\${b.dataset.tlToggle}"]\`).length }; })()`); const click = async () => { for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) await send("Input.dispatchMouseEvent", { type, x: tg.x, y: tg.y, button: "left", clickCount: 1 }); await sleep(150); }; await click(); const folded = await visibleTasks(); check(folded === before - tg.n, `folding an epic hides its ${tg.n} tasks (${before} → ${folded} visible)`); await click(); check((await visibleTasks()) === before, `unfolding shows them again`); const w0 = await labelW(); const c = await ev(`(() => { const r = document.querySelector("[data-tl-split]").getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`); await drag(c.x, c.y, 160); const w1 = await labelW(); check(Math.abs(w1 - (w0 + 160)) <= 2, `dragging the header border widens the labels (${w0} → ${w1})`); const r = await ev(`(() => { const r = document.querySelector(".tl-row.tl-task .tl-label").getBoundingClientRect(); return { x: r.right - 1, y: r.top + r.height / 2 }; })()`); await drag(r.x, r.y, -100); const w2 = await labelW(); check(Math.abs(w2 - (w1 - 100)) <= 2, `dragging a row's border narrows the labels (${w1} → ${w2})`); await ev(`document.querySelector("[data-tl-split]").focus()`); await send("Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 }); const w3 = await labelW(); check(w3 === w2 + 20, `ArrowRight moves the border 20px (${w2} → ${w3})`); await go("/timeline?scale=week"); const w4 = await labelW(); check(w4 === w3, `width is remembered after a reload (${w4})`); await shot("timeline-resized"); await ev(`document.querySelector("[data-tl-split]").focus()`); await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Home", code: "Home", windowsVirtualKeyCode: 36 }); check((await labelW()) === w0, `Home goes back to the default width (${w0})`); } finally { ws?.close(); proc.kill(); } process.exit(failed ? 1 : 0);