From cd1a48b6158f8f8ae2482a8e21147ce10222e58d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 12 Nov 2026 14:10:04 +0000 Subject: [PATCH] fix(orchestrator): keep 3D places and prove bubble reading in e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page-graph3d.js keeps node places across refreshes; the e2e helpers wait for the shown screen. page-bubbles.js marks each fading bubble with data-read, the word count its reading reached, and the W6 e2e test records bubble changes with a MutationObserver instead of sampling frames: counts never go down, data-read is 13, the bubble fades and leaves, and the reading lasts 4.233 to 6.333 s. A mutation run that stopped or rewound the reading failed both widths. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 86 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- orchestrator/templates/page-bubbles.js | 13 ++++- orchestrator/templates/page-graph3d.js | 11 +++++ tests/e2e/test_orchestrator_e2e.py | 29 ++++++++--- tests/e2e/test_workbench_w6_e2e.py | 68 ++++++++++++++++++-------- 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/orchestrator/templates/page-bubbles.js b/orchestrator/templates/page-bubbles.js index 12d06e0..ffad52c 100644 --- a/orchestrator/templates/page-bubbles.js +++ b/orchestrator/templates/page-bubbles.js @@ -58,10 +58,18 @@ view.node.style.setProperty("--tail", `${fixed((middle - left) / scale)}px`); } + function saidOf(item) { + return item.words || Bubbles.words(item.line); + } + + function wordsRead(item) { + return item.started ? Bubbles.shown(saidOf(item).length, item.duration, (performance.now() - item.started) / 1000) : 0; + } + function drawBubble(entry, view) { const {item} = entry; - const said = item.words || Bubbles.words(item.line); - const count = item.started ? Bubbles.shown(said.length, item.duration, (performance.now() - item.started) / 1000) : 0; + const said = saidOf(item); + const count = wordsRead(item); const text = said.slice(0, count).join(" ") || "…"; if (view.words.textContent !== text) { view.words.textContent = text; @@ -81,6 +89,7 @@ for (const [item, view] of bubbles.shown) { if (!kept.has(item)) { bubbles.shown.delete(item); + view.node.dataset.read = String(wordsRead(item)); view.node.classList.add("leaving"); window.setTimeout(() => view.node.remove(), BUBBLE_FADE); } diff --git a/orchestrator/templates/page-graph3d.js b/orchestrator/templates/page-graph3d.js index 6d66734..819cd7f 100644 --- a/orchestrator/templates/page-graph3d.js +++ b/orchestrator/templates/page-graph3d.js @@ -128,6 +128,16 @@ } } + function keepPlaces(nodes) { + const laid = new Map(g3.graph.graphData().nodes.filter((node) => Number.isFinite(node.x)).map((node) => [node.id, node])); + for (const node of nodes) { + const old = laid.get(node.id); + if (old) { + Object.assign(node, {x: old.x, y: old.y, z: old.z}); + } + } + } + function colors3d() { const css = window.getComputedStyle(document.documentElement); const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888"; @@ -394,6 +404,7 @@ } } const layout = layoutOf(tab); + keepPlaces(data.nodes); place(data.nodes, layout); g3.badges = new Map(); g3.faces = new Set(); diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index e790560..950f774 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -69,9 +69,23 @@ HELPERS = """ return rows; }; const taskLayout = () => all("#main .graph.tasks > *").map((node) => (node.matches('[role="toolbar"]') ? "toolbar" : node.matches('[aria-label="Task filters"]') ? "chips" : "body")); + const screenShown = async (ms) => { + const shown = await until(() => { + if (typeof Nav !== "object") { + return false; + } + const route = Nav.route(location.hash); + const view = route.view ? Nav.viewsOf(route.screen).find(([id]) => id === route.view)[1] : null; + const pressed = name(document.querySelector('#main [aria-label="View"] button[aria-pressed="true"]')); + return document.getElementById("main").getAttribute("aria-labelledby") === `tab-${route.screen}` && (!view || pressed === view); + }, ms); + await sleep(0); + return Boolean(shown); + }; """ PROBE = """(async () => {@HELPERS@ - await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000); + await screenShown(8000); + await until(() => !(text(document.querySelector("#main .space-note")) || "").startsWith("Loading") && !all("#main .empty h2").some((title) => text(title).endsWith("…")), 8000); const badge = (button) => { const found = button.querySelector(".badge"); return found && !found.hidden ? text(found) : null; @@ -772,8 +786,10 @@ SPOT_SEND = """(async () => {@HELPERS@ field.dispatchEvent(new Event("input", {bubbles: true})); const shown = new Set(all("#toasts .toast")); const prompts = questions.length; - form.querySelector('button[type="submit"]').click(); + const submit = form.querySelector('button[type="submit"]'); + submit.click(); const toast = await until(() => all("#toasts .toast").filter((node) => !shown.has(node)).map(text).find((entry) => entry.includes(" to ")), 20000); + await until(() => !submit.disabled, 20000); found.runs.push({cls, direct, toast, question: questions.slice(prompts), left: field.value}); } document.getElementById("toasts").replaceChildren(); @@ -821,13 +837,13 @@ PRESENCE_WALK = """(async () => {@HELPERS@@PRESENCE@ const views = {}; for (const view of ["orbit", "chord", "swimlane", "tree", "hierarchy"]) { location.hash = `#agents/${view}`; + await screenShown(6000); await until(() => all("#main .presence:not(label)").length, 6000); - await sleep(300); views[view] = states(); } location.hash = "#agents/orbit"; - await sleep(700); - const node = document.querySelector('#main svg .node[data-pick="s1"]'); + await screenShown(6000); + const node = await until(() => document.querySelector('#main svg .node[data-pick="s1"]'), 6000); const motion = getComputedStyle(node.querySelector(".presence .glyph")).animationName; node.dispatchEvent(new MouseEvent("click", {bubbles: true})); const mark = await until(() => document.querySelector("#popup .presence"), 4000); @@ -842,7 +858,8 @@ PRESENCE_WALK = """(async () => {@HELPERS@@PRESENCE@ menu.querySelector('input[data-look="faces"]').click(); const hidden = await graph3d("0"); location.hash = "#agents/orbit"; - await sleep(700); + await screenShown(6000); + await until(() => document.querySelector('#main svg .node[data-pick="s2"]'), 6000); return {views, motion, profile, shown, before, hidden, orbit: states(), @FITS@}; })()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS).replace("@FITS@", FITS) PRESENCE_READ = """(async () => {@HELPERS@@PRESENCE@ diff --git a/tests/e2e/test_workbench_w6_e2e.py b/tests/e2e/test_workbench_w6_e2e.py index 73ce4fd..182f24f 100644 --- a/tests/e2e/test_workbench_w6_e2e.py +++ b/tests/e2e/test_workbench_w6_e2e.py @@ -20,6 +20,9 @@ OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w6-shots")) CUE = "e2e-bubbles" FIRST = "Disk almost full on the build host, the gate waits for space now" SECOND = "Gate green for the merge" +READING = max(1.5, len(FIRST.split()) * 60 / 180) +EARLY = 0.1 +LATE = 2.0 VIEWS = {1920: "agents/orbit", 1366: "stream/radio", 390: "agents/hierarchy"} SECOND_FROM = {1920: "Skills", 1366: "Skills", 390: "Docs"} SECOND_ANCHORED = {1920: True, 1366: False, 390: False} @@ -28,6 +31,7 @@ GUARD = """() => { window.e2eViolations = []; document.addEventListener("securitypolicyviolation", (event) => window.e2eViolations.push(`${event.violatedDirective} ${event.blockedURI}`)); }""" +HOOKED = f"() => {{\n ({GUARD})();\n ({base.GRAPH_HOOK})();\n}}" READ = """ const toggle = document.getElementById("bubbles-toggle"); const layer = document.getElementById("bubbles"); @@ -50,6 +54,17 @@ READ = """ """ BUBBLES_ON = """(async () => {@HELPERS@@READ@ const before = [toggle.getAttribute("aria-pressed"), layer.hidden]; + window.e2eBubbleLog = []; + const note = (node) => window.e2eBubbleLog.push({ + t: performance.now(), who: node.dataset.who, state: node.dataset.state || null, words: Number(node.dataset.words || 0), + leaving: node.classList.contains("leaving"), read: node.dataset.read === undefined ? null : Number(node.dataset.read), removed: !node.isConnected, + }); + new MutationObserver((records) => { + for (const record of records) { + const gone = [...record.removedNodes].filter((node) => node instanceof Element && node.matches(".speech-bubble") && !node.isConnected); + [...(record.type === "attributes" && record.target.matches(".speech-bubble") ? [record.target] : []), ...gone].forEach(note); + } + }).observe(layer, {subtree: true, childList: true, attributes: true, attributeFilter: ["class", "data-state", "data-words", "data-read"]}); toggle.click(); const on = Boolean(await until(() => toggle.getAttribute("aria-pressed") === "true" && !layer.hidden, 8000)); await cue("@CUE@"); @@ -60,23 +75,25 @@ BUBBLES_ON = """(async () => {@HELPERS@@READ@ return {before, on, first: read(speaking()), second: read(waiting())}; })()""" BUBBLES_GROW = """(async () => {@HELPERS@@READ@ - const who = speaking() ? speaking().dataset.who : null; - const seen = []; - let [grown, faded, gone] = [null, null, null]; - const end = Date.now() + 15000; - while (who && !grown && Date.now() < end) { - const now = speaking(); - if (now && now.dataset.who === who) { - seen.push(Number(now.dataset.words)); - } else if (now) { - faded = Boolean(layer.querySelector(`.speech-bubble.leaving[data-who="${CSS.escape(who)}"]`)); - await sleep(450); - gone = !layer.querySelector(`.speech-bubble[data-who="${CSS.escape(who)}"]`); - grown = read(speaking()); - } - await sleep(40); + const log = window.e2eBubbleLog || []; + const who = (log.find((entry) => entry.state === "speaking") || {}).who || null; + const mine = () => log.filter((entry) => entry.who === who); + const left = await until(() => mine().find((entry) => entry.leaving), 15000); + let grown = null; + if (left) { + await Promise.all((speaking() ? speaking().getAnimations() : []).map((animation) => animation.finished.catch(() => null))); + grown = read(speaking()); + await until(() => mine().some((entry) => entry.removed), 4000); } - return {who, seen: seen.filter((value, index) => value !== seen[index - 1]), faded, gone, grown}; + const entries = mine(); + const fade = entries.findIndex((entry) => entry.leaving); + const reading = entries.slice(0, fade < 0 ? entries.length : fade).filter((entry) => entry.state === "speaking").map((entry) => entry.words); + const start = entries.find((entry) => entry.state === "speaking"); + return { + who, words: reading.filter((value, index) => value !== reading[index - 1]), read: left ? left.read : null, + seconds: start && left ? Math.round(left.t - start.t) / 1000 : null, + faded: fade > 0 && entries.slice(fade).every((entry) => entry.leaving), gone: entries.some((entry) => entry.removed), grown, + }; })()""" BUBBLES_DONE = """(async () => {@HELPERS@@READ@ const cleared = Boolean(await until(() => !layer.querySelector(".speech-bubble"), 10000)); @@ -85,7 +102,15 @@ BUBBLES_DONE = """(async () => {@HELPERS@@READ@ })()""" BUBBLES_3D = """(async () => {@HELPERS@@READ@ const canvas = Boolean(await until(() => document.querySelector("#main .graph3d canvas"), 15000)); - await sleep(1500); + await until(() => { + const graph = (window.e2eGraphs || []).at(-1); + const nodes = graph ? graph.graphData().nodes : []; + const frame = document.querySelector("#main .graph3d").getBoundingClientRect(); + return nodes.length && nodes.every((node) => { + const at = Number.isFinite(node.x) ? graph.graph2ScreenCoords(node.x, node.y, node.z) : {x: -1, y: -1}; + return at.x >= 0 && at.y >= 0 && at.x <= frame.width && at.y <= frame.height; + }); + }, 15000); await cue("@CUE@"); if (!(await until(speaking, 25000))) { return {canvas, error: "no bubble in 3D", bubbles: live().map(read)}; @@ -189,10 +214,11 @@ class TestWorkbenchBubbles(HubCase): motion = "opacity" if calm and width == 390 else "transform, opacity" self.assertEqual((first["transition"], second["transition"]), (motion, motion)) self.assertEqual(grow["who"], first_key) - self.assertGreaterEqual(len(grow["seen"]), 3, grow) - self.assertEqual(grow["seen"], sorted(grow["seen"]), grow) - self.assertEqual(grow["seen"][-1], len(FIRST.split()), grow) + self.assertGreaterEqual(len(grow["words"]), 2, grow) + self.assertEqual(grow["words"], sorted(grow["words"]), grow) + self.assertEqual(grow["read"], len(FIRST.split()), grow) self.assertEqual((grow["faded"], grow["gone"]), (True, True), grow) + self.assertTrue(READING - EARLY <= grow["seconds"] <= READING + LATE, grow) grown = grow["grown"] self.assertEqual((grown["who"], grown["scale"], grown["rendered"], grown["state"]), (second_key, 1, 1, "speaking"), grown) self.assertEqual(grown["text"], " ".join(SECOND.split()[: grown["words"]])) @@ -209,7 +235,7 @@ class TestWorkbenchBubbles(HubCase): self.assertFalse(self.httpd.hub.settings.get()["bubbles"]) def test_chromium_reads_two_rows_without_tts_as_growing_bubbles_keeps_the_switch_and_hides_the_layer_at_three_widths(self): - data = self.drive(self.steps("chromium", True, True), None, init=GUARD) + data = self.drive(self.steps("chromium", True, True), None, init=HOOKED) (OUT / "chromium-bubbles.json").write_text(json.dumps(data, indent=1), encoding="utf-8") self.assertEqual(data["errors"], []) self.check(data, "chromium", True, True)