fix(orchestrator): keep 3D places and prove bubble reading in e2e

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 14:10:04 +00:00
1 parent fd8fc08603
commit cd1a48b615
4 files changed
+92 -29

No files matched your search

+11 -2
View File
@@ -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);
}
+11
View File
@@ -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();
+23 -6
View File
@@ -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@
+47 -21
View File
@@ -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)