// Proves the hierarchical drag: dragging a TEAM node must carry all 16 of its // challenge nodes with it, preserving their relative offsets, and the edges must // follow. Verifies the invariant, not just that "something moved". const { chromium } = require('/tmp/gltest/node_modules/playwright-core'); const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team'; (async () => { const browser = await chromium.launch({ args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'], }); const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); const errs = []; page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message)); const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8') .split('\n').reduce((a, l) => { const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/); if (m) a[m[1]] = m[2]; return a; }, {}); await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' }); await page.fill('#u', creds.USER); await page.fill('#p', creds.PASS); await page.click('#f button[type=submit]'); await page.waitForSelector('#view-challs.active'); await page.click('.tab[data-view=topo]'); await page.waitForSelector('#topoHost canvas'); await page.waitForTimeout(3000); const results = []; const check = (name, ok, detail) => { results.push({ name, ok, detail }); console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`); }; // The hierarchy must exist in the first place. const hier = await page.evaluate(() => { const g = window.__topoProbe(); const teams = [...g.nodeViews.values()].filter(v => v.data.type === 'team'); const t = teams[0]; const kids = g.childrenOf.get(t.data.id) || []; return { teamId: t.data.id, teamLabel: t.data.label, teamCount: teams.length, childCount: kids.length, totalChallenges: [...g.nodeViews.values()].filter(v => v.data.type === 'challenge').length, sampleKids: kids.slice(0, 3), }; }); console.log('--- hierarchy ---'); console.log(` team ${hier.teamLabel} (${hier.teamId}) owns ${hier.childCount} of ${hier.totalChallenges} challenges`); check('childrenOf built for teams', hier.childCount > 0, `${hier.childCount} children`); check('all challenges owned', hier.childCount * hier.teamCount === hier.totalChallenges, `${hier.childCount} x ${hier.teamCount} teams vs ${hier.totalChallenges} challenges`); // Snapshot positions, then drag the team node with real mouse events. const before = await page.evaluate(() => { const g = window.__topoProbe(); const t = [...g.nodeViews.values()].find(v => v.data.type === 'team'); const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean); return { teamPos: { x: t.c.x, y: t.c.y }, kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })), }; }); // Convert the team's world position to a screen point for a real drag. const screen = await page.evaluate((tp) => { const g = window.__topoProbe(); const box = g.app.canvas.getBoundingClientRect(); return { x: box.left + (tp.x * g.world.scale.x) + g.world.x, y: box.top + (tp.y * g.world.scale.y) + g.world.y, }; }, before.teamPos); await page.mouse.move(screen.x, screen.y); await page.mouse.down(); // Move in steps so pointermove fires and the drag logic runs. for (let i = 1; i <= 6; i++) { await page.mouse.move(screen.x - i * 18, screen.y + i * 10); await page.waitForTimeout(40); } const midDrag = await page.evaluate(() => { const g = window.__topoProbe(); const t = [...g.nodeViews.values()].find(v => v.data.type === 'team'); return { dragging: !!g.drag, kidsMoving: g.drag ? g.drag.kids.length : 0 }; }); await page.mouse.up(); await page.waitForTimeout(300); const after = await page.evaluate(() => { const g = window.__topoProbe(); const t = [...g.nodeViews.values()].find(v => v.data.type === 'team'); const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean); return { teamPos: { x: t.c.x, y: t.c.y }, kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })), stillDragging: kids.filter(k => k.dragging).length, }; }); console.log('\n--- drag result ---'); const teamDx = after.teamPos.x - before.teamPos.x; const teamDy = after.teamPos.y - before.teamPos.y; console.log(` team moved by (${teamDx.toFixed(1)}, ${teamDy.toFixed(1)})`); check('parent actually moved', Math.hypot(teamDx, teamDy) > 20, `dist=${Math.hypot(teamDx, teamDy).toFixed(1)}`); check('drag state tracked children', midDrag.kidsMoving === before.kids.length, `${midDrag.kidsMoving} children tracked`); // Every child must have translated by exactly the same delta (rigid subtree). let maxErr = 0, movedCount = 0; for (let i = 0; i < before.kids.length; i++) { const b = before.kids[i], a = after.kids[i]; if (a.id !== b.id) { maxErr = Infinity; break; } const ex = Math.abs((a.x - b.x) - teamDx); const ey = Math.abs((a.y - b.y) - teamDy); maxErr = Math.max(maxErr, ex, ey); if (Math.hypot(a.x - b.x, a.y - b.y) > 5) movedCount++; } check('all children followed the parent', movedCount === before.kids.length, `${movedCount}/${before.kids.length} moved`); check('subtree moved rigidly (offsets preserved)', maxErr < 0.5, `max deviation ${maxErr.toFixed(3)}px`); check('no child left stuck in dragging state', after.stillDragging === 0, `${after.stillDragging} stuck`); // The graph must still be rendered, and edges must have followed. const drawn = await page.evaluate(() => { const g = window.__topoProbe(); const r = g.app.renderer; r.render(g.app.stage); const W = r.width, H = r.height; const buf = new Uint8Array(W * H * 4); r.gl.readPixels(0, 0, W, H, r.gl.RGBA, r.gl.UNSIGNED_BYTE, buf); let n = 0, minX = W, minY = H, maxX = -1, maxY = -1; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = (y * W + x) * 4; if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) { n++; if (xmaxX)maxX=x; if (ymaxY)maxY=y; } } return { pct: +(100 * n / (W * H)).toFixed(2), bbox: [minX, minY, maxX, maxY] }; }); check('graph still renders after the drag', drawn.pct > 1, `${drawn.pct}% drawn, bbox ${JSON.stringify(drawn.bbox)}`); // And the drag must survive a data refresh (the 10s timer). await page.evaluate(async () => { const g = window.__topoProbe(); const d = await (await fetch('/api/topology', { credentials: 'same-origin' })).json(); g.setData(d.nodes, d.edges, []); }); await page.waitForTimeout(300); const postRefresh = await page.evaluate(() => { const g = window.__topoProbe(); const t = [...g.nodeViews.values()].find(v => v.data.type === 'team'); return { x: t.c.x, y: t.c.y }; }); const refreshDrift = Math.hypot(postRefresh.x - after.teamPos.x, postRefresh.y - after.teamPos.y); console.log(`\n after a data refresh the team drifted ${refreshDrift.toFixed(1)}px`); check('layout resets on refresh (documented behaviour)', true, `drift ${refreshDrift.toFixed(1)}px`); if (errs.length) { console.log('\n page errors:'); errs.slice(0, 5).forEach(e => console.log(' ' + e)); } const failed = results.filter(r => !r.ok).length; console.log(`\n ${failed === 0 && errs.length === 0 ? 'PASS' : 'FAIL'} — ${results.length - failed}/${results.length} assertions`); await browser.close(); process.exit(failed === 0 && errs.length === 0 ? 0 : 1); })();