ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/e2e/timeline-check.mjs

// End-to-end check of the Timeline layout in a real browser.
// Usage (tracker must be running): node e2e/timeline-check.mjs <path-to-chrome> <base-url> <screenshot-dir>
// 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 <chrome> <base-url> <screenshot-dir>");
  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);