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`);
|
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) {
|
function drawBubble(entry, view) {
|
||||||
const {item} = entry;
|
const {item} = entry;
|
||||||
const said = item.words || Bubbles.words(item.line);
|
const said = saidOf(item);
|
||||||
const count = item.started ? Bubbles.shown(said.length, item.duration, (performance.now() - item.started) / 1000) : 0;
|
const count = wordsRead(item);
|
||||||
const text = said.slice(0, count).join(" ") || "…";
|
const text = said.slice(0, count).join(" ") || "…";
|
||||||
if (view.words.textContent !== text) {
|
if (view.words.textContent !== text) {
|
||||||
view.words.textContent = text;
|
view.words.textContent = text;
|
||||||
@@ -81,6 +89,7 @@
|
|||||||
for (const [item, view] of bubbles.shown) {
|
for (const [item, view] of bubbles.shown) {
|
||||||
if (!kept.has(item)) {
|
if (!kept.has(item)) {
|
||||||
bubbles.shown.delete(item);
|
bubbles.shown.delete(item);
|
||||||
|
view.node.dataset.read = String(wordsRead(item));
|
||||||
view.node.classList.add("leaving");
|
view.node.classList.add("leaving");
|
||||||
window.setTimeout(() => view.node.remove(), BUBBLE_FADE);
|
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() {
|
function colors3d() {
|
||||||
const css = window.getComputedStyle(document.documentElement);
|
const css = window.getComputedStyle(document.documentElement);
|
||||||
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
|
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
|
||||||
@@ -394,6 +404,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const layout = layoutOf(tab);
|
const layout = layoutOf(tab);
|
||||||
|
keepPlaces(data.nodes);
|
||||||
place(data.nodes, layout);
|
place(data.nodes, layout);
|
||||||
g3.badges = new Map();
|
g3.badges = new Map();
|
||||||
g3.faces = new Set();
|
g3.faces = new Set();
|
||||||
|
|||||||
@@ -69,9 +69,23 @@ HELPERS = """
|
|||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
const taskLayout = () => all("#main .graph.tasks > *").map((node) => (node.matches('[role="toolbar"]') ? "toolbar" : node.matches('[aria-label="Task filters"]') ? "chips" : "body"));
|
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@
|
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 badge = (button) => {
|
||||||
const found = button.querySelector(".badge");
|
const found = button.querySelector(".badge");
|
||||||
return found && !found.hidden ? text(found) : null;
|
return found && !found.hidden ? text(found) : null;
|
||||||
@@ -772,8 +786,10 @@ SPOT_SEND = """(async () => {@HELPERS@
|
|||||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||||
const shown = new Set(all("#toasts .toast"));
|
const shown = new Set(all("#toasts .toast"));
|
||||||
const prompts = questions.length;
|
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);
|
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});
|
found.runs.push({cls, direct, toast, question: questions.slice(prompts), left: field.value});
|
||||||
}
|
}
|
||||||
document.getElementById("toasts").replaceChildren();
|
document.getElementById("toasts").replaceChildren();
|
||||||
@@ -821,13 +837,13 @@ PRESENCE_WALK = """(async () => {@HELPERS@@PRESENCE@
|
|||||||
const views = {};
|
const views = {};
|
||||||
for (const view of ["orbit", "chord", "swimlane", "tree", "hierarchy"]) {
|
for (const view of ["orbit", "chord", "swimlane", "tree", "hierarchy"]) {
|
||||||
location.hash = `#agents/${view}`;
|
location.hash = `#agents/${view}`;
|
||||||
|
await screenShown(6000);
|
||||||
await until(() => all("#main .presence:not(label)").length, 6000);
|
await until(() => all("#main .presence:not(label)").length, 6000);
|
||||||
await sleep(300);
|
|
||||||
views[view] = states();
|
views[view] = states();
|
||||||
}
|
}
|
||||||
location.hash = "#agents/orbit";
|
location.hash = "#agents/orbit";
|
||||||
await sleep(700);
|
await screenShown(6000);
|
||||||
const node = document.querySelector('#main svg .node[data-pick="s1"]');
|
const node = await until(() => document.querySelector('#main svg .node[data-pick="s1"]'), 6000);
|
||||||
const motion = getComputedStyle(node.querySelector(".presence .glyph")).animationName;
|
const motion = getComputedStyle(node.querySelector(".presence .glyph")).animationName;
|
||||||
node.dispatchEvent(new MouseEvent("click", {bubbles: true}));
|
node.dispatchEvent(new MouseEvent("click", {bubbles: true}));
|
||||||
const mark = await until(() => document.querySelector("#popup .presence"), 4000);
|
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();
|
menu.querySelector('input[data-look="faces"]').click();
|
||||||
const hidden = await graph3d("0");
|
const hidden = await graph3d("0");
|
||||||
location.hash = "#agents/orbit";
|
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@};
|
return {views, motion, profile, shown, before, hidden, orbit: states(), @FITS@};
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS).replace("@FITS@", FITS)
|
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS).replace("@FITS@", FITS)
|
||||||
PRESENCE_READ = """(async () => {@HELPERS@@PRESENCE@
|
PRESENCE_READ = """(async () => {@HELPERS@@PRESENCE@
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w6-shots"))
|
|||||||
CUE = "e2e-bubbles"
|
CUE = "e2e-bubbles"
|
||||||
FIRST = "Disk almost full on the build host, the gate waits for space now"
|
FIRST = "Disk almost full on the build host, the gate waits for space now"
|
||||||
SECOND = "Gate green for the merge"
|
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"}
|
VIEWS = {1920: "agents/orbit", 1366: "stream/radio", 390: "agents/hierarchy"}
|
||||||
SECOND_FROM = {1920: "Skills", 1366: "Skills", 390: "Docs"}
|
SECOND_FROM = {1920: "Skills", 1366: "Skills", 390: "Docs"}
|
||||||
SECOND_ANCHORED = {1920: True, 1366: False, 390: False}
|
SECOND_ANCHORED = {1920: True, 1366: False, 390: False}
|
||||||
@@ -28,6 +31,7 @@ GUARD = """() => {
|
|||||||
window.e2eViolations = [];
|
window.e2eViolations = [];
|
||||||
document.addEventListener("securitypolicyviolation", (event) => window.e2eViolations.push(`${event.violatedDirective} ${event.blockedURI}`));
|
document.addEventListener("securitypolicyviolation", (event) => window.e2eViolations.push(`${event.violatedDirective} ${event.blockedURI}`));
|
||||||
}"""
|
}"""
|
||||||
|
HOOKED = f"() => {{\n ({GUARD})();\n ({base.GRAPH_HOOK})();\n}}"
|
||||||
READ = """
|
READ = """
|
||||||
const toggle = document.getElementById("bubbles-toggle");
|
const toggle = document.getElementById("bubbles-toggle");
|
||||||
const layer = document.getElementById("bubbles");
|
const layer = document.getElementById("bubbles");
|
||||||
@@ -50,6 +54,17 @@ READ = """
|
|||||||
"""
|
"""
|
||||||
BUBBLES_ON = """(async () => {@HELPERS@@READ@
|
BUBBLES_ON = """(async () => {@HELPERS@@READ@
|
||||||
const before = [toggle.getAttribute("aria-pressed"), layer.hidden];
|
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();
|
toggle.click();
|
||||||
const on = Boolean(await until(() => toggle.getAttribute("aria-pressed") === "true" && !layer.hidden, 8000));
|
const on = Boolean(await until(() => toggle.getAttribute("aria-pressed") === "true" && !layer.hidden, 8000));
|
||||||
await cue("@CUE@");
|
await cue("@CUE@");
|
||||||
@@ -60,23 +75,25 @@ BUBBLES_ON = """(async () => {@HELPERS@@READ@
|
|||||||
return {before, on, first: read(speaking()), second: read(waiting())};
|
return {before, on, first: read(speaking()), second: read(waiting())};
|
||||||
})()"""
|
})()"""
|
||||||
BUBBLES_GROW = """(async () => {@HELPERS@@READ@
|
BUBBLES_GROW = """(async () => {@HELPERS@@READ@
|
||||||
const who = speaking() ? speaking().dataset.who : null;
|
const log = window.e2eBubbleLog || [];
|
||||||
const seen = [];
|
const who = (log.find((entry) => entry.state === "speaking") || {}).who || null;
|
||||||
let [grown, faded, gone] = [null, null, null];
|
const mine = () => log.filter((entry) => entry.who === who);
|
||||||
const end = Date.now() + 15000;
|
const left = await until(() => mine().find((entry) => entry.leaving), 15000);
|
||||||
while (who && !grown && Date.now() < end) {
|
let grown = null;
|
||||||
const now = speaking();
|
if (left) {
|
||||||
if (now && now.dataset.who === who) {
|
await Promise.all((speaking() ? speaking().getAnimations() : []).map((animation) => animation.finished.catch(() => null)));
|
||||||
seen.push(Number(now.dataset.words));
|
grown = read(speaking());
|
||||||
} else if (now) {
|
await until(() => mine().some((entry) => entry.removed), 4000);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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@
|
BUBBLES_DONE = """(async () => {@HELPERS@@READ@
|
||||||
const cleared = Boolean(await until(() => !layer.querySelector(".speech-bubble"), 10000));
|
const cleared = Boolean(await until(() => !layer.querySelector(".speech-bubble"), 10000));
|
||||||
@@ -85,7 +102,15 @@ BUBBLES_DONE = """(async () => {@HELPERS@@READ@
|
|||||||
})()"""
|
})()"""
|
||||||
BUBBLES_3D = """(async () => {@HELPERS@@READ@
|
BUBBLES_3D = """(async () => {@HELPERS@@READ@
|
||||||
const canvas = Boolean(await until(() => document.querySelector("#main .graph3d canvas"), 15000));
|
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@");
|
await cue("@CUE@");
|
||||||
if (!(await until(speaking, 25000))) {
|
if (!(await until(speaking, 25000))) {
|
||||||
return {canvas, error: "no bubble in 3D", bubbles: live().map(read)};
|
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"
|
motion = "opacity" if calm and width == 390 else "transform, opacity"
|
||||||
self.assertEqual((first["transition"], second["transition"]), (motion, motion))
|
self.assertEqual((first["transition"], second["transition"]), (motion, motion))
|
||||||
self.assertEqual(grow["who"], first_key)
|
self.assertEqual(grow["who"], first_key)
|
||||||
self.assertGreaterEqual(len(grow["seen"]), 3, grow)
|
self.assertGreaterEqual(len(grow["words"]), 2, grow)
|
||||||
self.assertEqual(grow["seen"], sorted(grow["seen"]), grow)
|
self.assertEqual(grow["words"], sorted(grow["words"]), grow)
|
||||||
self.assertEqual(grow["seen"][-1], len(FIRST.split()), grow)
|
self.assertEqual(grow["read"], len(FIRST.split()), grow)
|
||||||
self.assertEqual((grow["faded"], grow["gone"]), (True, True), grow)
|
self.assertEqual((grow["faded"], grow["gone"]), (True, True), grow)
|
||||||
|
self.assertTrue(READING - EARLY <= grow["seconds"] <= READING + LATE, grow)
|
||||||
grown = grow["grown"]
|
grown = grow["grown"]
|
||||||
self.assertEqual((grown["who"], grown["scale"], grown["rendered"], grown["state"]), (second_key, 1, 1, "speaking"), 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"]]))
|
self.assertEqual(grown["text"], " ".join(SECOND.split()[: grown["words"]]))
|
||||||
@@ -209,7 +235,7 @@ class TestWorkbenchBubbles(HubCase):
|
|||||||
self.assertFalse(self.httpd.hub.settings.get()["bubbles"])
|
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):
|
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")
|
(OUT / "chromium-bubbles.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
self.assertEqual(data["errors"], [])
|
self.assertEqual(data["errors"], [])
|
||||||
self.check(data, "chromium", True, True)
|
self.check(data, "chromium", True, True)
|
||||||
|
|||||||
Reference in new issue
Block a user