diff --git a/panel/run_topo_tests.sh b/panel/run_topo_tests.sh new file mode 100644 index 0000000..5b3d782 --- /dev/null +++ b/panel/run_topo_tests.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run the topology test suite and report one line per test. +set -u +export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright +cd /opt/gemastik18-final/panel || exit 1 + +fail=0 +for t in test_topo_pixels test_topo_browser test_topo_viewports test_topo_race test_topo_drag; do + log="/tmp/${t}.log" + timeout 400 node "${t}.js" > "$log" 2>&1 + code=$? + if [ $code -eq 0 ]; then + echo "PASS ${t} (exit 0)" + else + echo "FAIL ${t} (exit ${code})" + tail -12 "$log" | sed 's/^/ /' + fail=1 + fi +done +exit $fail diff --git a/panel/static/index.html b/panel/static/index.html index 523d8ff..a959822 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -549,8 +549,6 @@ async function viewCred(ch) { } catch (e) { toast(e.message, true); } } -// ---------- Topology ---------- -let topoLoaded = false; // ---------- Topology (PixiJS v8) ---------- // The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is // only parsed when this tab is actually opened. These functions are the thin @@ -558,6 +556,17 @@ let topoLoaded = false; let topoAttacks = []; let topoGraph = null; let topoModule = null; +// Single-flight guard. The Topology tab button calls `showView('topo'); loadTopo()` +// and showView() also calls loadTopo() for this view, so two loadTopo() run +// concurrently on every click. Previously the re-entry check was +// `if (topoGraph && topoGraph.app)`, but `topoGraph` was assigned BEFORE the +// awaited init(), so `app` was still null and the second call built a SECOND +// renderer. Both ran `host.innerHTML = ''` and appended their own canvas, so +// whichever init() finished last won the DOM while `topoGraph` still referenced +// the other — a live canvas that was no longer on the page. Track the in-flight +// promise so every caller awaits the same init. +let topoInitPromise = null; +let topoLoadPromise = null; async function topoEngine() { if (!topoModule) topoModule = await import('/static/topo_pixi.js'); @@ -568,24 +577,41 @@ async function ensureTopoGraph() { const host = document.getElementById('topoHost'); if (!host) return null; if (topoGraph && topoGraph.app) return topoGraph; - const mod = await topoEngine(); - topoGraph = new mod.TopoGraph(host); - await topoGraph.init(); - const lbl = document.getElementById('topoEngine'); - if (lbl) lbl.textContent = '· rendered with PixiJS v8'; - return topoGraph; + if (topoInitPromise) return topoInitPromise; // an init is already running + topoInitPromise = (async () => { + try { + const mod = await topoEngine(); + const g = new mod.TopoGraph(host); + await g.init(); + topoGraph = g; // publish only when ready + const lbl = document.getElementById('topoEngine'); + if (lbl) lbl.textContent = '· rendered with PixiJS v8'; + return g; + } finally { + topoInitPromise = null; + } + })(); + return topoInitPromise; } async function loadTopo() { - try { - const g = await ensureTopoGraph(); - if (!g) return; - const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]); - topoAttacks = a.events || []; - g.setData(d.nodes, d.edges, topoAttacks); - } catch (e) { - toast('Topologi gagal: ' + e.message, true); - } + // The 10s timer plus the tab click both land here; serialise so two refreshes + // never interleave a scene rebuild. + if (topoLoadPromise) return topoLoadPromise; + topoLoadPromise = (async () => { + try { + const g = await ensureTopoGraph(); + if (!g) return; + const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]); + topoAttacks = a.events || []; + g.setData(d.nodes, d.edges, topoAttacks); + } catch (e) { + toast('Topologi gagal: ' + e.message, true); + } finally { + topoLoadPromise = null; + } + })(); + return topoLoadPromise; } function topoZoom(factor) { diff --git a/panel/static/topo_pixi.js b/panel/static/topo_pixi.js index 3ce7f92..7c40f5e 100644 --- a/panel/static/topo_pixi.js +++ b/panel/static/topo_pixi.js @@ -57,6 +57,8 @@ export class TopoGraph { this.attackLayer = null; this.nodeLayer = null; this.nodeViews = new Map(); // id -> {container, data} + this.edgeViews = new Map(); // 'from->to' -> {line, label} + this.childrenOf = new Map(); // parent id -> [child ids], for hierarchical drag this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail} this.scale = 1; this.panX = 0; @@ -110,18 +112,25 @@ export class TopoGraph { this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer); this._bindPointer(); - // The attack pulse is the only per-frame work; everything else is redrawn - // on data refresh, so keep the ticker cheap. + // Render on demand: the ticker is registered but NOT started. It only runs + // while something is animating (recent attacks) or the user is dragging, and + // stops again once the scene is quiet. A continuous 60fps repaint of a static + // graph measured 2 FPS / 1353ms main-thread lag on this host. + this._ticking = false; this.app.ticker.add((ticker) => this._animate(ticker.deltaMS)); + this.app.ticker.stop(); + this.requestRender(); // paint the (still empty) canvas once return this; } destroy() { + if (this._resizeHandler) window.removeEventListener('resize', this._resizeHandler); if (this.app) { try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ } } this.app = null; this.nodeViews.clear(); + this.edgeViews.clear(); this.attackViews.clear(); } @@ -199,6 +208,36 @@ export class TopoGraph { this.worldW = W; this.worldH = H; + // Parent -> children index, for hierarchical dragging. Ownership comes from + // the edge list (the same source the lines are drawn from), so the drag + // hierarchy can never disagree with what is drawn. Challenges that are + // missing from the edge list (should not happen) still get attached to their + // team by the `teamN-` id prefix, so a challenge is never orphaned. + const owned = new Map(); + for (const e of this.edges) { + const a = this.nodes.find(n => n.id === e.from); + const b = this.nodes.find(n => n.id === e.to); + if (!a || !b) continue; + // The parent is whichever end is a team (or an infra node), never a challenge. + let parent = null, child = null; + if (a.type === 'challenge' && b.type === 'team') { parent = b; child = a; } + else if (b.type === 'challenge' && a.type === 'team') { parent = a; child = b; } + else if (a.type === 'challenge' && b.type !== 'challenge') { parent = b; child = a; } + else if (b.type === 'challenge' && a.type !== 'challenge') { parent = a; child = b; } + if (!parent || !child) continue; + if (!owned.has(parent.id)) owned.set(parent.id, []); + const list = owned.get(parent.id); + if (!list.includes(child.id)) list.push(child.id); + } + for (const n of this.nodes) { + if (n.type !== 'challenge') continue; + const teamId = 'team' + n.id.split('-')[0].replace('team', ''); + if (!this.nodes.some(x => x.id === teamId)) continue; + if (!owned.has(teamId)) owned.set(teamId, []); + if (!owned.get(teamId).includes(n.id)) owned.get(teamId).push(n.id); + } + this.childrenOf = owned; + // Edges are cheap and fully derived -> clear and redraw wholesale. this._redrawEdges(pos); this._syncNodes(pos); @@ -207,6 +246,9 @@ export class TopoGraph { // the graph hanging off the right edge. if (this._needsFit) { this._needsFit = false; this.fit(); } else this.applyView(); + // Recurring attacks are the only thing that needs continuous frames; the + // static graph is already painted by applyView() above. + if (this._attacksAreAnimating()) this._startTicking(); } _syncNodes(pos) { @@ -245,29 +287,52 @@ export class TopoGraph { const theme = this._themeFor(n); const r = theme.r; const fillAlpha = theme.alpha ?? 1; - view.disc.clear() - .circle(0, 0, r) - .fill({ color: theme.fill, alpha: fillAlpha }) - .stroke({ width: 1.5, color: theme.stroke, alpha: 1 }); - view.halo.clear() - .circle(0, 0, Math.max(1, r - 4)) - .stroke({ width: 1, color: theme.stroke, alpha: 0.3 }); - view.title.style.fill = theme.color; - view.title.style.fontSize = n.type === 'team' ? 12 : 10; - view.title.text = n.label; - const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); - view.sub.text = sub || ''; - view.sub.visible = !!sub; - view.title.y = sub ? 0 : -4; - view.sub.y = 12; + // Assigning `.text` re-rasterises the glyphs and re-uploads the text + // texture, so only do it when the string or the visual style actually + // changed. Doing it unconditionally on every 10s refresh was the other + // half of the main-thread stall. + if (view.discKey !== `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`) { + view.discKey = `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`; + view.disc.clear() + .circle(0, 0, r) + .fill({ color: theme.fill, alpha: fillAlpha }) + .stroke({ width: 1.5, color: theme.stroke, alpha: 1 }); + view.halo.clear() + .circle(0, 0, Math.max(1, r - 4)) + .stroke({ width: 1, color: theme.stroke, alpha: 0.3 }); + } + const titleSize = n.type === 'team' ? 12 : 10; + if (view.titleKey !== `${n.label}|${theme.color}|${titleSize}`) { + view.titleKey = `${n.label}|${theme.color}|${titleSize}`; + view.title.style.fill = theme.color; + view.title.style.fontSize = titleSize; + view.title.text = n.label; + } + const subText = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); + if (view.subText !== subText) { + view.subText = subText; + view.sub.text = subText || ''; + view.sub.visible = !!subText; + view.title.y = subText ? 0 : -4; + view.sub.y = 12; + } // Position only when it changed: writing x/y every refresh would fight a // drag in progress and make the graph jitter under the cursor. - if (!view.dragging) view.c.position.set(p.x, p.y); - view.dot.clear(); - if (n.status === 'running') { - view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running }); + if (!view.dragging && (view.posKey !== `${p.x},${p.y}`)) { + view.posKey = `${p.x},${p.y}`; + view.c.position.set(p.x, p.y); } - if (n.tooltip) { + const running = n.status === 'running'; + if (view.running !== running) { + view.running = running; + view.dot.clear(); + if (running) view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running }); + } + // Bind the tooltip ONCE, at creation. Re-binding inside the refresh loop + // attached a new pointerover listener every 10s, so after an hour a single + // hover fired thousands of handlers. + if (n.tooltip && !view.hasTooltip) { + view.hasTooltip = true; view.c.on('pointerover', () => this._setTooltip(n)); view.c.on('pointerout', () => this._setTooltip(null)); } @@ -342,20 +407,64 @@ export class TopoGraph { } // ---- animation --------------------------------------------------------- + // Render on demand, NOT every frame. + // + // Measured on this host: with the ticker running continuously, requestAnimation + // Frame dropped to 2 FPS and a `setTimeout(0)` round trip took 1353ms, which + // froze the whole panel every frame and read as "the graph disappeared". + // With the ticker stopped: 72 FPS and a 15ms lag. The graph is static between + // the 10s data refreshes, so a continuous 60fps repaint buys nothing and costs + // everything. + // + // So: the ticker is only started while something is actually animating (a + // recent attack pulse) or the user is interacting (pan/zoom/drag), and it is + // stopped again as soon as the scene goes quiet. Every mutation calls + // requestRender() to guarantee at least one repaint. + requestRender() { + this._needsRender = true; + this.app.render(); // paint once, synchronously + } + + _startTicking() { + if (this._ticking || !this.app) return; + this._ticking = true; + this.app.ticker.start(); + } + + _stopTicking() { + if (!this._ticking || !this.app) return; + this._ticking = false; + this.app.ticker.stop(); + } + + // Attack pulse: only worth animating while an attack is recent (60s window). + // Outside that window every line is static, so the ticker can be parked. + _attacksAreAnimating() { + if (!this.attackViews.size) return false; + const now = Date.now() / 1000; + for (const [, view] of this.attackViews) { + if (now - (view.latest || 0) < 60) return true; + } + return false; + } + _animate(deltaMS) { - // Throttle to ~30fps: the ticker runs even when the tab is hidden and this - // host only has 2 cores. 33ms budget between alpha updates. this.ticker += deltaMS; - if (this.ticker < 33) return; + if (this.ticker < 66) { // ~15fps is plenty for a 1.2s pulse + if (this._attacksAreAnimating()) this.app.render(); + return; + } this.ticker = 0; const now = Date.now() / 1000; + let stillAnimating = false; for (const [k, view] of this.attackViews) { const hot = now - (view.latest || 0) < 60; + if (hot) stillAnimating = true; // 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s). const phase = (now % 1.2) / 1.2; view.line.alpha = hot ? 0.55 + 0.45 * Math.sin(phase * Math.PI) : 0.9; // Recent attacks read as a dashed "hot" line; redraw only on state change - // so we are not re-tessellating 30x a second for nothing. + // so we are not re-tessellating every frame for nothing. const wantDash = hot ? '6 3' : '4 4'; if (view.dash !== wantDash) { view.dash = wantDash; @@ -368,6 +477,8 @@ export class TopoGraph { } } } + // Nothing left to animate: stop paying for frames. + if (!stillAnimating) this._stopTicking(); } // ---- pan / zoom / drag ------------------------------------------------- @@ -409,10 +520,31 @@ export class TopoGraph { const world = this.toWorld(ev); view.dragging = true; container.cursor = 'grabbing'; - this.drag = { view, dx: container.x - world.x, dy: container.y - world.y }; + // Dragging a parent must carry its children: a team node owns its 16 + // challenge nodes, and a challenge belongs to exactly one team. Snapshot the + // children with their current offsets so the whole subtree translates as one + // rigid group instead of leaving the children behind. + const children = (this.childrenOf.get(view.data.id) || []) + .map((cid) => this.nodeViews.get(cid)) + .filter((cv) => cv && cv.c !== container); + const kids = children.map((cv) => ({ + view: cv, + dx: cv.c.x - container.x, + dy: cv.c.y - container.y, + })); + this.drag = { view, dx: container.x - world.x, dy: container.y - world.y, kids }; + this._startTicking(); // keep painting while the pointer is down const move = (e) => { const w = this.toWorld(e); container.position.set(w.x + this.drag.dx, w.y + this.drag.dy); + // Children ride along with the parent, keeping their original offsets. + // Mark them dragging too, or the next 10s setData() will snap them back to + // their computed layout slot and undo the user's arrangement. + for (const k of this.drag.kids) { + k.view.dragging = true; + k.view.c.position.set(container.x + k.dx, container.y + k.dy); + k.view.posKey = null; // force a clean write next refresh + } // Edges/attacks originate at node centres, so a dragged node must // redraw them or the graph lies about the topology. this._redrawForDrag(); @@ -420,6 +552,12 @@ export class TopoGraph { const up = () => { view.dragging = false; container.cursor = 'grab'; + // Children keep their dragged position: the user's arrangement is the new + // baseline. Leaving `dragging` set would make the subtree permanently + // immune to later layout changes; clearing it means the next setData() + // refresh may re-layout them, which is the documented 10s refresh + // behaviour for every other node. + for (const k of this.drag ? this.drag.kids : []) k.view.dragging = false; this.drag = null; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); @@ -445,25 +583,47 @@ export class TopoGraph { _redrawEdges(pos) { const PIXI = this.PIXI; - this.edgeLayer.removeChildren().forEach(c => c.destroy({ children: true })); + // Edges are fully derived from node positions, so they are rebuilt when a + // node moves. But the objects are NOT thrown away each time: every new + // PIXI.Text allocates a canvas, rasterises glyphs and uploads a texture to + // the GPU. Destroying and recreating ~70 Graphics + ~36 Text on the 10s + // refresh blocked the main thread for ~1.6s per cycle, which froze the page + // and made the graph look like it had vanished. Keep a per-edge view and + // only redraw the geometry, and only repaint a label when its text changes. + const seen = new Set(); for (const e of this.edges) { const a = pos[e.from], b = pos[e.to]; if (!a || !b) continue; - const g = new PIXI.Graphics(); - g.moveTo(a.x, a.y).lineTo(b.x, b.y) - .stroke({ width: 1.5, color: THEME.edge, alpha: 1 }); - g.eventMode = 'none'; - this.edgeLayer.addChild(g); - if (e.label) { - const t = new PIXI.Text({ - text: e.label, - style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' }, - }); - t.anchor.set(0.5); - t.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6); - t.eventMode = 'none'; - this.edgeLayer.addChild(t); + const key = `${e.from}->${e.to}`; + seen.add(key); + let view = this.edgeViews.get(key); + if (!view) { + const line = new PIXI.Graphics(); + line.eventMode = 'none'; + const label = e.label + ? new PIXI.Text({ + text: e.label, + style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' }, + }) + : null; + if (label) { label.anchor.set(0.5); label.eventMode = 'none'; } + this.edgeLayer.addChild(line); + if (label) this.edgeLayer.addChild(label); + view = { line, label, labelText: label ? e.label : null }; + this.edgeViews.set(key, view); } + view.line.clear().moveTo(a.x, a.y).lineTo(b.x, b.y) + .stroke({ width: 1.5, color: THEME.edge, alpha: 1 }); + if (view.label) { + if (view.labelText !== e.label) { view.label.text = e.label; view.labelText = e.label; } + view.label.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6); + } + } + for (const [key, view] of [...this.edgeViews]) { + if (seen.has(key)) continue; + view.line.destroy(); + if (view.label) view.label.destroy(); + this.edgeViews.delete(key); } } @@ -517,6 +677,9 @@ export class TopoGraph { } applyView() { + // Called on every pan, zoom, drag and refit: repaint synchronously so the + // view tracks the pointer even with the ticker parked. + this.requestRender(); if (!this.app || !this.world) return; this.world.position.set(this.panX || 0, this.panY || 0); this.world.scale.set(this.scale); diff --git a/panel/test_topo_drag.js b/panel/test_topo_drag.js new file mode 100644 index 0000000..b343021 --- /dev/null +++ b/panel/test_topo_drag.js @@ -0,0 +1,160 @@ +// 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); +})(); diff --git a/panel/test_topo_race.js b/panel/test_topo_race.js new file mode 100644 index 0000000..25916ba --- /dev/null +++ b/panel/test_topo_race.js @@ -0,0 +1,107 @@ +// The topology "disappears" for the user but passed my tests. Two suspects in +// index.html: +// 1. The Topology tab button calls `showView('topo'); loadTopo()`, and +// showView ITSELF calls loadTopo() when v==='topo' -> two concurrent calls. +// 2. ensureTopoGraph() assigns `topoGraph = new TopoGraph(host)` BEFORE awaiting +// init(), but the guard is `if (topoGraph && topoGraph.app)` — and app is +// null until init() finishes. A second concurrent call therefore builds a +// SECOND renderer. Both run host.innerHTML = '' and append their own canvas, +// so whichever init() finishes LAST wins the DOM while `topoGraph` still +// points at the other one -> a canvas that is running but detached from the +// page, i.e. nothing visible. +// Hammer the tab exactly like a user clicking it, and check how many canvases +// exist and which one the bridge holds. +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)); + page.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); }); + + 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'); + + // Count how many times the module actually initialises a renderer. + await page.evaluate(() => { + window.__inits = 0; + const host = document.getElementById('topoHost'); + const mo = new MutationObserver(() => { /* canvas churn visible below */ }); + mo.observe(host, { childList: true }); + window.__mo = mo; + }); + + console.log('=== single tab click (what my tests did) ==='); + await page.click('.tab[data-view=topo]'); + await page.waitForTimeout(2500); + let s = await page.evaluate(() => { + const host = document.getElementById('topoHost'); + const g = window.__topoProbe ? window.__topoProbe() : null; + return { + canvases: host.querySelectorAll('canvas').length, + hostChildren: host.children.length, + bridgeHasApp: !!(g && g.app), + bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected), + nodes: g ? g.nodeViews.size : -1, + }; + }); + console.log(' ', JSON.stringify(s)); + + console.log('\n=== rapid re-entry (leave tab, come back, x3) ==='); + for (let i = 0; i < 3; i++) { + await page.click('.tab[data-view=challs]'); + await page.waitForTimeout(120); + await page.click('.tab[data-view=topo]'); + await page.waitForTimeout(900); + const t = await page.evaluate(() => { + const host = document.getElementById('topoHost'); + const g = window.__topoProbe ? window.__topoProbe() : null; + return { + canvases: host.querySelectorAll('canvas').length, + hostChildren: host.children.length, + bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected), + nodes: g ? g.nodeViews.size : -1, + }; + }); + console.log(` round ${i + 1}:`, JSON.stringify(t)); + } + + console.log('\n=== final pixel check on the VISIBLE canvas ==='); + const r = await page.evaluate(() => { + const host = document.getElementById('topoHost'); + const cv = host.querySelector('canvas'); + if (!cv) return { error: 'no canvas in host at all' }; + const g = window.__topoProbe(); + const rd = g.app.renderer; + rd.render(g.app.stage); + const W = rd.width, H = rd.height; + const buf = new Uint8Array(W * H * 4); + rd.gl.readPixels(0, 0, W, H, rd.gl.RGBA, rd.gl.UNSIGNED_BYTE, buf); + let nonBg = 0; + for (let i = 0; i < buf.length; i += 4) + if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) nonBg++; + return { + bridgeCanvasIsTheVisibleOne: cv === g.app.canvas, + pct: +(100 * nonBg / (W * H)).toFixed(2), + visibleCanvasW: cv.width, bridgeCanvasW: rd.width, + }; + }); + console.log(' ', JSON.stringify(r)); + if (errs.length) { console.log('\n errors:'); errs.slice(0, 6).forEach(e => console.log(' ' + e)); } + await browser.close(); +})(); diff --git a/panel/verify_topo_full.sh b/panel/verify_topo_full.sh new file mode 100644 index 0000000..a1d610b --- /dev/null +++ b/panel/verify_topo_full.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Cold-start verification: restart the panel, run the whole topology suite, and +# confirm the platform SLA is unaffected. This is the check to run after any +# change to the topology renderer. +set -u +export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright + +echo "=== restart panel ===" +systemctl restart gemastik-panel +sleep 7 +echo "gemastik-panel: $(systemctl is-active gemastik-panel)" + +echo +echo "=== topology test suite ===" +bash /opt/gemastik18-final/panel/run_topo_tests.sh +suite=$? + +echo +echo "=== platform SLA ===" +curl -sS "http://127.0.0.1:18081/api/public/scoreboard" | python3 -c " +import json, sys +d = json.load(sys.stdin) +ta = tt = 0 +for t in d.get('teams', []): + a, n = t.get('alive', 0), t.get('total', 0) + ta += a; tt += n + print(f\" team {t.get('label')}: SLA {a}/{n}\") +print(f' TOTAL: {ta}/{tt}' + (f' ({100*ta/tt:.0f}%)' if tt else ' (no teams)')) +" + +echo +echo "=== verdict ===" +if [ $suite -eq 0 ]; then + echo " topology suite PASS" +else + echo " topology suite FAIL" +fi +exit $suite