fix(web-runtime): focus descends into the shadow root here too

The Go driver already descends the boundary; the V8 host did not, so the
two enumerations disagreed about focus on any shadow-mounted app.

The harness now answers activeElement the way a real root does: a root
names a node of its own tree, so only the shadow root itself names the
field.
This commit is contained in:
pj committed 2026-08-17 23:48:14 +05:30
1 parent e432af7828
commit 6706461b91
3 files changed
+76 -6

No files matched your search

+19
View File
@@ -53,12 +53,14 @@ export interface FakeElementSpec {
// overflows makes the element's content taller than its box, which is how the
// host decides an element is scrollable.
overflows?: boolean;
focused?: boolean;
children?: FakeElementSpec[];
shadow?: FakeElementSpec[];
}
export interface FakeRoot {
children: FakeElement[];
activeElement: FakeElement | null;
querySelectorAll(selector: string): FakeElement[];
}
@@ -139,11 +141,28 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
function fakeRoot(children: FakeElement[]): FakeRoot {
const root: FakeRoot = {
children,
get activeElement(): FakeElement | null {
return activeElementIn(children);
},
querySelectorAll: (selector: string) => queryScope(root, selector),
};
return root;
}
// A root answers activeElement with a node of its OWN tree, as the browser
// does: focus inside a shadow root names the host, and only that root's own
// activeElement names the field. Reporting the field from both roots would let
// a runtime that never descends still pass.
function activeElementIn(nodes: FakeElement[]): FakeElement | null {
for (const node of nodes) {
if (node.focused) return node;
if (node.shadowRoot?.activeElement) return node;
const inside = activeElementIn(node.children);
if (inside) return inside;
}
return null;
}
function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] {
const found: FakeElement[] = [];
const walk = (nodes: FakeElement[]): void => {
+36
View File
@@ -967,6 +967,42 @@ test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first"
});
});
// document.activeElement stops at every shadow boundary it meets, so a Compose
// for Web page, which mounts its whole tree in a shadow root, answered `focused`
// on the mount element and never on the field the user was typing into.
// internal/driver/chrome/driver.go descends the same chain for the dump the goja
// host reads, so a handle that compares against the host alone puts the two
// hosts on opposite answers for one page.
test("ax.find reports focus on the field inside the shadow root, not on its hosts", () => {
const app = fakeElement({
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "app",
shadow: [
{
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "form",
shadow: [
{
tag: "input", x: 0, y: 0, width: 200, height: 40, id: "amount",
editable: true, focused: true,
},
],
},
],
});
withFakeDocument([app], () => {
__testing__.extractors.length = 0;
for (const id of ["amount", "form", "app"]) {
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
return ax.find(`id:${id}`)?.focused;
});
}
const values = __testing__.evaluateExtractors();
assert.equal(readingOf(values, 0), true);
assert.equal(readingOf(values, 1), false);
assert.equal(readingOf(values, 2), false);
});
});
// A nested undefined is the one reading shape the two hosts do NOT encode
// alike, and this pins the split instead of hiding it. JSON has no undefined,
// so the key goes with the value here; goja marshals the same member as null,