// Drive a real timeline drag headlessly to prove the magnet: two clips on DIFFERENT tracks; drag the // second so its start nears the first's END — assert it locks EXACTLY to that edge and a guide line shows. import { spawn, spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import path from "node:path"; import WebSocket from "ws"; const CHROME = process.env.CHROME_BIN || "chromium-browser"; const CDP_PORT = Number(process.env.CDP_PORT || (9400 + Math.floor(Math.random() * 400))); const HOME = process.env.HOME || "/tmp"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let BROWSER = null; async function launchChrome() { const userDir = mkdtempSync(path.join(HOME, "elg-snap-")); const proc = spawn(CHROME, ["--headless=new","--no-sandbox","--disable-gpu","--disable-dev-shm-usage","--no-first-run",`--remote-debugging-port=${CDP_PORT}`,`--user-data-dir=${userDir}`,"--window-size=1600,1000","about:blank"], { stdio: "ignore", detached: true }); for (let i = 0; i < 150; i++) { try { const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`); if (r.ok) { BROWSER = { proc, userDir, browserWsUrl: (await r.json()).webSocketDebuggerUrl }; return; } } catch {} await sleep(200); } throw new Error("chromium CDP not up"); } async function shutdown(code) { const b = BROWSER; if (b) { try { b.proc.kill("SIGKILL"); } catch {} try { spawnSync("pkill", ["-9", "-f", b.userDir]); } catch {} try { rmSync(b.userDir, { recursive: true, force: true }); } catch {} } process.exit(code); } class CDP { constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.handlers = new Map(); ws.on("message", (raw) => { const m = JSON.parse(raw); if (m.id && this.pending.has(m.id)) { const { res, rej } = this.pending.get(m.id); this.pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); } else if (m.method) (this.handlers.get(m.method) || []).forEach((h) => h(m.params)); }); } send(method, params = {}) { const id = ++this.id; return new Promise((res, rej) => { this.pending.set(id, { res, rej }); this.ws.send(JSON.stringify({ id, method, params })); }); } once(method) { return new Promise((res) => { const a = this.handlers.get(method) || []; a.push(res); this.handlers.set(method, a); }); } } async function openPage(url) { const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new?${encodeURIComponent(url)}`, { method: "PUT" }); const tab = await r.json(); const ws = new WebSocket(tab.webSocketDebuggerUrl, { perMessageDeflate: false, maxPayload: 64 * 1024 * 1024 }); await new Promise((res, rej) => { ws.once("open", res); ws.once("error", rej); }); const cdp = new CDP(ws); await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); return cdp; } async function evalJs(cdp, expr) { const { result, exceptionDetails } = await cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true }); if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || "eval threw"); return result.value; } const script = `(async () => { const S = window.useStore; if (!S) return { error: "no store" }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); if (!S.getState().loaded) { try { await S.getState().bootProject(); } catch (e) {} } for (let i = 0; i < 50 && !S.getState().loaded; i++) await sleep(150); await sleep(400); // clean 2-track scenario (in-memory only; setState doesn't autosave → demo on disk stays intact) const t1 = { id: "tk_scenes", kind: "video", name: "S", role: "scenes" }, t2 = { id: "tk_v2", kind: "video", name: "V2" }; const A = { id: "mA", template: "bigStatement", props: { statement: "A" }, theme: "dark", accent: "#E7723B", anim: "rise", label: "AAAZ", trackId: "tk_scenes", startMs: 2000, durationMs: 2000 }; const B = { id: "mB", template: "bigStatement", props: { statement: "B" }, theme: "dark", accent: "#5DE3FF", anim: "rise", label: "BBBZ", trackId: "tk_v2", startMs: 5000, durationMs: 2000 }; window.useStore.setState({ motion: [A, B], videos: [] , tracks: [t1, t2] }); S.getState().setPlayhead(0); await sleep(600); const A_end = 4000; const before = { guides: document.querySelectorAll('[class*="z-40"]').length }; // locate B's clip div by its title const el = [...document.querySelectorAll('[title]')].find(e => (e.getAttribute('title') || '').startsWith('BBBZ')); if (!el) return { error: "no B clip element found" }; const r = el.getBoundingClientRect(); const pxps = r.width / (B.durationMs / 1000); // clip width encodes px-per-second → robust to zoom const cx = r.left + r.width / 2, cy = r.top + r.height / 2; // move B left by ~950ms so its start (5000) lands ~4050 → inside the magnet of A's end (4000) const dpx = (950 / 1000) * pxps; el.dispatchEvent(new PointerEvent('pointerdown', { clientX: cx, clientY: cy, button: 0, bubbles: true, cancelable: true })); await sleep(20); window.dispatchEvent(new PointerEvent('pointermove', { clientX: cx - 5, clientY: cy, bubbles: true })); await sleep(20); window.dispatchEvent(new PointerEvent('pointermove', { clientX: Math.round(cx - dpx), clientY: cy, bubbles: true })); await sleep(50); const duringStart = S.getState().motion.find(m => m.id === 'mB').startMs; const guidesDuring = document.querySelectorAll('[class*="z-40"]').length; window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); await sleep(60); const finalStart = S.getState().motion.find(m => m.id === 'mB').startMs; return { pxps: Math.round(pxps), duringStart, finalStart, snappedToEdge: finalStart === A_end, guideAppeared: guidesDuring > before.guides }; })()`; const run = async () => { await launchChrome(); const cdp = await openPage("http://localhost:5174"); await Promise.race([cdp.once("Page.loadEventFired"), sleep(8000)]); await sleep(1500); await evalJs(cdp, "try{localStorage.setItem('ed.tourDone','1')}catch(e){}; location.reload(); true"); await Promise.race([cdp.once("Page.loadEventFired"), sleep(8000)]); await sleep(3500); const r = await evalJs(cdp, script); console.log("RESULT:", JSON.stringify(r, null, 2)); console.log(r && r.snappedToEdge && r.guideAppeared ? "\n✓ PASS — dragged clip snapped EXACTLY to the other track's edge, guide line shown" : "\n✗ CHECK above"); }; run().then(() => shutdown(0)).catch(async (e) => { console.error("error:", e.message); await shutdown(1); });