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:
1 parent
fd8fc08603
commit
cd1a48b615
4 files changed
+92
-29
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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@
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user