From 4f46a230feb2d7d4678880b75dcdb764ae43c5cd Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 13:20:17 +0000 Subject: [PATCH 1/2] test(console): name the focused element when the mobile drawer check fails Finding 2016: console.test.mjs:846 failed once as 'false !== true', so the element that held focus was unknown. The opened-drawer check now waits up to 1 s for focus to reach the Agents link (or Settings item), logs a diagnostic when it settles after starting elsewhere, and names the focused element, the first sample and the drawer state on failure. The post-release check keeps its strict single sample and names the focused element too. --- tests/browser/console.test.mjs | 45 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/tests/browser/console.test.mjs b/tests/browser/console.test.mjs index 1694d9463d..00a65565c5 100644 --- a/tests/browser/console.test.mjs +++ b/tests/browser/console.test.mjs @@ -89,6 +89,37 @@ test("a collection body disconnected after headers shows interruption and Retry ); }); +// Waits up to settleMs for the locator's element to hold focus. Returns what held focus at the +// first and the last check (role, accessible text, class, connected), so a failed check names +// the element that took focus instead of saying `false !== true` (finding 2016). +async function focusSettles(locator, settleMs = 1000) { + const deadline = Date.now() + settleMs; + let first; + for (;;) { + const sample = await locator.evaluate((node) => { + const active = globalThis.document.activeElement; + const describe = (element) => { + if (!element || element === globalThis.document.body) { + return element ? "" : "nothing"; + } + const role = element.getAttribute("role"); + const name = (element.getAttribute("aria-label") ?? element.textContent ?? "").trim(); + return ( + `<${element.localName}${role ? ` role=${role}` : ""}` + + `${element.className ? ` class="${element.className}"` : ""}> "${name.slice(0, 60)}"` + + `${element.isConnected ? "" : " (detached)"}` + ); + }; + return { held: node === active, active: describe(active), target: describe(node) }; + }); + first ??= sample; + if (sample.held || Date.now() >= deadline) { + return { ...sample, first: first.active }; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + async function openShellMenu(page) { await page.getByRole("button", { name: /OpenClaw Enterprise/ }).click(); } @@ -843,9 +874,15 @@ for (const focus of ["navigation link", "account menu"]) { focused = page.getByRole("menuitem", { name: "Settings", exact: true }); } await focused.waitFor(); + const opened = await focusSettles(focused); + if (opened.held && opened.first !== opened.active) { + t.diagnostic(`focus settled on the ${focus} after ${opened.first}`); + } assert.equal( - await focused.evaluate((node) => node === globalThis.document.activeElement), + opened.held, true, + `The opened drawer focuses its ${focus} ${opened.target}; focus is on ${opened.active}` + + ` (first check: ${opened.first}; drawer ${await toggle.getAttribute("aria-expanded")})`, ); await releaseHeldRoute(page, namespacesPattern, namespacesHold); await page.getByRole("list", { name: "Namespaces", exact: true }).waitFor(); @@ -853,10 +890,12 @@ for (const focus of ["navigation link", "account menu"]) { assert.equal(await toggle.getAttribute("aria-expanded"), "true", "The drawer stays open"); assert.equal(await page.locator("main").evaluate((node) => node.inert), true); + // No settling here: the re-render must not move focus at all. + const kept = await focusSettles(focused, 0); assert.equal( - await focused.evaluate((node) => node === globalThis.document.activeElement), + kept.held, true, - `The re-rendered drawer keeps focus on its ${focus}`, + `The re-rendered drawer keeps focus on its ${focus} ${kept.target}; focus is on ${kept.active}`, ); await focused.click(); const destination = focus === "account menu" ? "Settings" : "Agents"; From 615d6b1bd1064a2e93bc0c427cee420ef141a50a Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 13:39:07 +0000 Subject: [PATCH 2/2] test(console): read the focused element's class by attribute and the drawer state only on failure Review nits on finding 2016's helper: an SVG element's className is an object, and the drawer's aria-expanded was read even when the check passed. --- tests/browser/console.test.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/browser/console.test.mjs b/tests/browser/console.test.mjs index 00a65565c5..f86fb5c2e2 100644 --- a/tests/browser/console.test.mjs +++ b/tests/browser/console.test.mjs @@ -103,10 +103,12 @@ async function focusSettles(locator, settleMs = 1000) { return element ? "" : "nothing"; } const role = element.getAttribute("role"); + // getAttribute, not className: an SVG element's className is an object. + const className = element.getAttribute("class"); const name = (element.getAttribute("aria-label") ?? element.textContent ?? "").trim(); return ( `<${element.localName}${role ? ` role=${role}` : ""}` + - `${element.className ? ` class="${element.className}"` : ""}> "${name.slice(0, 60)}"` + + `${className ? ` class="${className}"` : ""}> "${name.slice(0, 60)}"` + `${element.isConnected ? "" : " (detached)"}` ); }; @@ -878,12 +880,12 @@ for (const focus of ["navigation link", "account menu"]) { if (opened.held && opened.first !== opened.active) { t.diagnostic(`focus settled on the ${focus} after ${opened.first}`); } - assert.equal( - opened.held, - true, - `The opened drawer focuses its ${focus} ${opened.target}; focus is on ${opened.active}` + - ` (first check: ${opened.first}; drawer ${await toggle.getAttribute("aria-expanded")})`, - ); + if (!opened.held) { + assert.fail( + `The opened drawer focuses its ${focus} ${opened.target}; focus is on ${opened.active}` + + ` (first check: ${opened.first}; drawer ${await toggle.getAttribute("aria-expanded")})`, + ); + } await releaseHeldRoute(page, namespacesPattern, namespacesHold); await page.getByRole("list", { name: "Namespaces", exact: true }).waitFor(); await page.locator('.content [aria-live="polite"][aria-busy="false"]').waitFor();