From 3cc7f6a4b672ce0be57f11a077a32d60433f073e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:32:05 +0530 Subject: [PATCH 1/8] test(spec): give the fake dom a real tree and a walking querySelectorAll --- pkg/spec/test/web-dom-harness.ts | 237 +++++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 24 deletions(-) diff --git a/pkg/spec/test/web-dom-harness.ts b/pkg/spec/test/web-dom-harness.ts index cc0e671..f35a094 100644 --- a/pkg/spec/test/web-dom-harness.ts +++ b/pkg/spec/test/web-dom-harness.ts @@ -1,8 +1,21 @@ -// A minimal stand-in for the DOM surface the web host reads, shared by the web -// runtime's own tests and the cross-host eligibility test. The host asks the -// document for three things -- every element, the tappable set, the editable set -// -- and reads geometry, `disabled` and the scroll extents off each element, so -// that is all a fake has to answer. +// A small DOM the web runtime can be driven over, shared by the web runtime's +// own tests and the cross-host eligibility test. +// +// It is a fake, but the structure is real: elements nest, a host owns a shadow +// root, and querySelectorAll WALKS the tree and stops at a shadow boundary +// exactly as the browser's does. That is what makes the shadow descent in +// deepQueryAll and expandShadowContent (src/web-runtime.ts) observable here at +// all; the previous harness answered three fixed selectors from a flat list, so +// deleting either descent changed no test result. +// +// What it fabricates is layout: getBoundingClientRect, scrollHeight and +// clientHeight are handed over from the spec. No headless DOM computes those, +// and they are precisely the facts collectTargets reads, so a real DOM +// implementation would have to be stubbed for them anyway. +// +// An unsupported selector throws rather than matching nothing, so a test whose +// selector this cannot parse fails loudly instead of quietly asserting over an +// empty list. import { __testing__ } from "../src/web-runtime.ts"; @@ -23,23 +36,45 @@ export interface FakeElementSpec { label?: string; alt?: string; title?: string; - // clickable/editable place the element in the selector sets the host queries; - // the fake answers those queries directly rather than matching CSS. + // text is what an ax element handle reports as `text`, the same field the + // goja host reads off a hierarchy node, so a test can name WHICH of two + // same-id elements a lookup resolved to. + text?: string; + attrs?: Record; + // clickable/editable place the element in the two fact sets the host queries + // by selector. They are answered from these flags rather than by matching + // their CSS: the cross-host golden (fixtures/host-parity-golden.json, built + // row for row in internal/verifier/host_parity_test.go) pins fact + // combinations no CSS can produce, such as an that is editable and + // not clickable. A test states the facts there; this harness reports them. clickable?: boolean; editable?: boolean; disabled?: boolean; // overflows makes the element's content taller than its box, which is how the // host decides an element is scrollable. overflows?: boolean; + children?: FakeElementSpec[]; + shadow?: FakeElementSpec[]; } -export interface FakeElement extends FakeElementSpec { +export interface FakeRoot { + children: FakeElement[]; + querySelectorAll(selector: string): FakeElement[]; +} + +export interface FakeElement extends Omit { tagName: string; type: string; isContentEditable: boolean; id: string; + className: string; + textContent: string; dataset: Record; + parentElement: FakeElement | null; + children: FakeElement[]; + shadowRoot: FakeRoot | null; getAttribute(name: string): string | null; + querySelectorAll(selector: string): FakeElement[]; scrollHeight: number; clientHeight: number; scrollWidth: number; @@ -56,15 +91,26 @@ export interface FakeElement extends FakeElementSpec { export function fakeElement(spec: FakeElementSpec): FakeElement { const editable = spec.editable ?? false; - return { + const attributes: Record = { ...spec.attrs }; + if (spec.id !== undefined) attributes.id = spec.id; + if (spec.testid !== undefined) attributes["data-testid"] = spec.testid; + if (spec.label !== undefined) attributes["aria-label"] = spec.label; + if (spec.alt !== undefined) attributes.alt = spec.alt; + if (spec.title !== undefined) attributes.title = spec.title; + const element: FakeElement = { ...spec, tagName: spec.tag.toUpperCase(), type: spec.tag === "input" ? "text" : "", isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea", id: spec.id ?? "", + className: attributes.class ?? "", + textContent: spec.text ?? "", dataset: { testid: spec.testid }, - getAttribute: (name: string) => - ({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? null, + parentElement: null, + children: (spec.children ?? []).map(fakeElement), + shadowRoot: null, + getAttribute: (name: string) => attributes[name] ?? null, + querySelectorAll: (selector: string) => queryScope(element, selector), scrollHeight: spec.overflows ? spec.height * 2 : spec.height, clientHeight: spec.height, scrollWidth: spec.width, @@ -78,27 +124,170 @@ export function fakeElement(spec: FakeElementSpec): FakeElement { bottom: spec.y + spec.height, }), }; + for (const child of element.children) child.parentElement = element; + if (spec.shadow) element.shadowRoot = fakeRoot(spec.shadow.map(fakeElement)); + return element; } -// withFakeDocument installs a document answering the host's three queries over -// `elements`, resets the host's per-tick cache, and restores the real document -// afterwards. +// A shadow root's children have no parentElement, as in a real DOM, so a +// descendant selector cannot reach across the boundary from either side. +function fakeRoot(children: FakeElement[]): FakeRoot { + const root: FakeRoot = { + children, + querySelectorAll: (selector: string) => queryScope(root, selector), + }; + return root; +} + +function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] { + const found: FakeElement[] = []; + const walk = (nodes: FakeElement[]): void => { + for (const node of nodes) { + if (matchesQuery(node, selector)) found.push(node); + walk(node.children); + } + }; + walk(scope.children); + return found; +} + +function matchesQuery(element: FakeElement, selector: string): boolean { + if (selector === TAPPABLE_SELECTOR) return element.clickable === true; + if (selector === EDITABLE_SELECTOR) return element.editable === true; + return matchesSelectorList(element, selector); +} + +function matchesSelectorList(element: FakeElement, selector: string): boolean { + return splitTopLevel(selector, ",").some((complex) => matchesComplex(element, complex)); +} + +function matchesComplex(element: FakeElement, complex: string): boolean { + const compounds = splitTopLevel(complex, " "); + const subject = compounds.pop(); + if (subject === undefined) return false; + if (!matchesCompound(element, subject)) return false; + let ancestor = element.parentElement; + for (const compound of compounds.reverse()) { + while (ancestor && !matchesCompound(ancestor, compound)) ancestor = ancestor.parentElement; + if (!ancestor) return false; + ancestor = ancestor.parentElement; + } + return true; +} + +const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*/; +const ATTRIBUTE = /^([a-zA-Z][\w-]*)(?:([~^]?)=(.+))?$/; + +function matchesCompound(element: FakeElement, compound: string): boolean { + let rest = compound; + while (rest.length > 0) { + if (rest.startsWith("*")) { + rest = rest.slice(1); + continue; + } + if (rest.startsWith("[")) { + const end = closingIndex(rest, "[", "]"); + if (!matchesAttribute(element, rest.slice(1, end))) return false; + rest = rest.slice(end + 1); + continue; + } + if (rest.startsWith(":is(") || rest.startsWith(":not(")) { + const end = closingIndex(rest, "(", ")"); + const inner = rest.slice(rest.indexOf("(") + 1, end); + const anyMatched = splitTopLevel(inner, ",").some((part) => + matchesSelectorList(element, part), + ); + if (rest.startsWith(":is(") ? !anyMatched : anyMatched) return false; + rest = rest.slice(end + 1); + continue; + } + const tag = TAG_NAME.exec(rest); + if (!tag) throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(compound)}`); + if (element.tagName !== tag[0].toUpperCase()) return false; + rest = rest.slice(tag[0].length); + } + return true; +} + +function matchesAttribute(element: FakeElement, body: string): boolean { + const parsed = ATTRIBUTE.exec(body); + if (!parsed) throw new Error(`web-dom-harness cannot parse attribute [${body}]`); + const [, name, operator, quoted] = parsed; + const actual = element.getAttribute(name!); + if (actual === null) return false; + if (quoted === undefined) return true; + const value = unescapeCss(quoted.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")); + if (operator === "~") return actual.split(/\s+/).includes(value); + if (operator === "^") return actual.startsWith(value); + return actual === value; +} + +// Selector values reach the harness escaped by CSS.escape, so `[id="1a"]` +// arrives as `[id="\31 a"]` and comparing it raw would never match. +function unescapeCss(value: string): string { + return value.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex: string, literal: string) => + hex ? String.fromCodePoint(parseInt(hex, 16)) : literal, + ); +} + +function closingIndex(input: string, open: string, close: string): number { + let depth = 0; + let quote = ""; + for (let index = input.indexOf(open); index < input.length; index++) { + const character = input[index]!; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === open) depth++; + else if (character === close && --depth === 0) return index; + } + throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(input)}`); +} + +function splitTopLevel(input: string, separator: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + let quote = ""; + for (const character of input) { + if (quote) { + current += character; + if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "(" || character === "[") depth++; + else if (character === ")" || character === "]") depth--; + else if (depth === 0 && (character === separator || (separator === " " && /\s/.test(character)))) { + parts.push(current); + current = ""; + continue; + } + current += character; + } + parts.push(current); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +// withFakeDocument installs a document whose top-level children are `elements`, +// resets the host's per-tick cache, and restores the real globals afterwards. +// window goes in alongside document because buildState reads both, so an +// extractor reaching state.ax needs it. export function withFakeDocument(elements: FakeElement[], run: () => void): void { const global = globalThis as Record; - const original = global.document; - const answers: Record = { - "*": elements, - [TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable), - [EDITABLE_SELECTOR]: elements.filter((element) => element.editable), - }; - global.document = { - querySelectorAll: (selector: string) => answers[selector] ?? [], - }; + const originalDocument = global.document; + const originalWindow = global.window; + const document: FakeRoot = fakeRoot(elements); + global.document = document; + global.window = {}; __testing__.resetTargetCache(); try { run(); } finally { __testing__.resetTargetCache(); - global.document = original; + global.document = originalDocument; + global.window = originalWindow; } } From 3fe972946d450ae0956c86731e3463c0552fa216 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:35:05 +0530 Subject: [PATCH 2/8] fix(spec): deepQueryAll returns matches in document order --- pkg/spec/src/web-runtime.ts | 11 ++- pkg/spec/test/web-runtime.test.ts | 150 +++++++++++++++++++++--------- 2 files changed, 113 insertions(+), 48 deletions(-) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 24deca8..a607758 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -193,13 +193,18 @@ function selectorFromString(selector: string): { css?: string; xpath?: string } // (Compose for Web mounts its canvas and its whole accessibility tree inside a // shadow root on the mount element) keeps its entire UI on the far side of one: // without this a spec sees four nodes and can neither enumerate a target nor -// resolve a testTag. Light-DOM matches come first, then shadow content in walk -// order. XPath has no equivalent, so `text:` selectors stop at the boundary. +// resolve a testTag. Matches come back in the order expandShadowContent walks +// and buildTree (internal/driver/chrome/driver.go) emits: a host, then that +// host's shadow content, then the host's light children. Sweeping the light DOM +// first and descending afterwards put a shadow-hosted match behind a later +// light-DOM one, so find() answered with a different element on each host. +// XPath has no equivalent, so `text:` selectors stop at the boundary. function deepQueryAll(selector: string, root: ParentNode): Element[] { const found: Element[] = []; const visit = (scope: ParentNode): void => { - for (const element of Array.from(scope.querySelectorAll(selector))) found.push(element); + const matched = new Set(Array.from(scope.querySelectorAll(selector))); for (const element of Array.from(scope.querySelectorAll("*"))) { + if (matched.has(element)) found.push(element); if (element.shadowRoot) visit(element.shadowRoot); } }; diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index a5ac7e6..5316fc9 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -84,6 +84,7 @@ test("installRuntime defined the host-invoked globals", () => { }); const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts"); +type FakeElementSpec = Parameters[0]; // The host reports facts and never routes verbs: which of these a verb may act // on is decided by the shared rule in src/targets.ts, exercised across both @@ -175,6 +176,55 @@ test("queryTargets leaves duplicated identities unnamed", () => { }); }); +// The enumeration ORDER is the parity contract. buildTree in +// internal/driver/chrome/driver.go emits a host's shadow children before its +// light ones, and TestHierarchy_DerivesTheSameFactsAsTheWebRuntime compares the +// two enumerations position by position. +test("queryTargets splices shadow content in before the host's light children", () => { + const page = fakeElement({ + tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page", + children: [ + { + tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount", + shadow: [ + { tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true }, + ], + children: [{ tag: "div", x: 0, y: 20, width: 40, height: 20, id: "mount-light-child" }], + }, + { tag: "div", x: 0, y: 100, width: 400, height: 100, id: "after" }, + ], + }); + withFakeDocument([page], () => { + assert.deepEqual( + host.queryTargets().map((target) => target.selector), + ["id:page", "id:mount", "id:shadow-save", "id:mount-light-child", "id:after"], + ); + }); +}); + +// The tappable set is resolved by selector, and querySelectorAll stops dead at +// a shadow boundary, so a control inside a shadow root carries the clickable +// fact only if the selector sweep descends. A Compose for Web app keeps every +// control it has on the far side of one boundary. +test("queryTargets reports a shadow-hosted control as clickable", () => { + const mount = fakeElement({ + tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount", + shadow: [ + { tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true }, + { tag: "input", x: 0, y: 20, width: 40, height: 20, id: "shadow-amount", editable: true }, + ], + }); + withFakeDocument([mount], () => { + const targets = host.queryTargets(); + assert.deepEqual( + targets.map((target) => target.selector), + ["id:mount", "id:shadow-save", "id:shadow-amount"], + ); + assert.equal(targets[1]!.clickable, true); + assert.equal(targets[2]!.editable, true); + }); +}); + test("queryTargets caches within a tick until reset", () => { const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true }); withFakeDocument([button], () => { @@ -473,28 +523,23 @@ test("selectorTag renders the selector shapes the goja host renders", () => { // accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}])) // were empty on every web step and the properties over them checked nothing. test("ax.findAll resolves a selector path segment by segment", () => { - const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }; - const node = (id: string, answers: Record = {}) => ({ - id, - tagName: "DIV", - className: "", - textContent: id, - dataset: {}, - getAttribute: () => null, - getBoundingClientRect: () => rect, - querySelectorAll: (selector: string) => answers[selector] ?? [], + const card = (id: string, y: number): FakeElementSpec => ({ + tag: "div", x: 0, y, width: 10, height: 10, testid: "AccountCard", text: id, + }); + // The stray card is outside HomeScreen, so a document-wide sweep for the + // second segment picks it up and the scoping assertion below fails. + const page = fakeElement({ + tag: "div", x: 0, y: 0, width: 100, height: 100, + children: [ + { + tag: "div", x: 0, y: 0, width: 100, height: 50, testid: "HomeScreen", + children: [card("first", 0), card("second", 10)], + }, + card("stray", 60), + ], }); - const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`; - const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`; - const cards = [node("first"), node("second")]; - const home = node("HomeScreen", { [cardCss]: cards }); - const g = globalThis as Record; - const originalDocument = g.document; - const originalWindow = g.window; - g.document = { querySelectorAll: (selector: string) => (selector === screenCss ? [home] : []) }; - g.window = {}; - try { + withFakeDocument([page], () => { __testing__.extractors.length = 0; __testing__.runtime.extract((state) => { const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; @@ -506,30 +551,14 @@ test("ax.findAll resolves a selector path segment by segment", () => { // Scoped to the head match: the cards come from the HomeScreen node, not // from a document-wide sweep for AccountCard. assert.deepEqual(readingOf(values, 0), ["first", "second"]); - } finally { - g.document = originalDocument; - g.window = originalWindow; - } + }); }); test("ax.find and ax.findAll label the element with its selector", () => { - const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }; - const submit = { - id: "TxnSubmit", - tagName: "DIV", - className: "", - textContent: "Submit", - dataset: {}, - getAttribute: () => null, - getBoundingClientRect: () => rect, - }; - const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`; - const g = globalThis as Record; - const originalDocument = g.document; - const originalWindow = g.window; - g.document = { querySelectorAll: (selector: string) => (selector === matches ? [submit] : []) }; - g.window = {}; - try { + const submit = fakeElement({ + tag: "div", x: 0, y: 0, width: 10, height: 10, id: "TxnSubmit", text: "Submit", + }); + withFakeDocument([submit], () => { __testing__.extractors.length = 0; __testing__.runtime.extract((state) => { const ax = (state as { ax: { find(s: unknown): Record | undefined } }).ax; @@ -546,8 +575,39 @@ test("ax.find and ax.findAll label the element with its selector", () => { // reference would hand the array INDEX to the runtime as the selector. const all = readingOf(values, 1) as Record[]; assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit"); - } finally { - g.document = originalDocument; - g.window = originalWindow; - } + }); +}); + +// One page, one selector, two hosts. The goja host resolves a selector against +// the hierarchy dump, whose buildTree (internal/driver/chrome/driver.go) emits +// a host's shadow children BEFORE its light ones, so a pre-order search there +// reaches a shadow-hosted match first. deepQueryAll swept the whole light DOM +// first and only then descended, so this page answered find({id:"x"}) with the +// light node in V8 and the shadow node in goja, and on web V8's answer is the +// one that reaches the properties. +test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first", () => { + const page = fakeElement({ + tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page", + children: [ + { + tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount", + shadow: [{ tag: "span", x: 0, y: 0, width: 40, height: 20, id: "x", text: "shadow" }], + }, + { tag: "span", x: 0, y: 100, width: 40, height: 20, id: "x", text: "light" }, + ], + }); + withFakeDocument([page], () => { + __testing__.extractors.length = 0; + __testing__.runtime.extract((state) => { + const ax = (state as { ax: { find(s: unknown): Record | undefined } }).ax; + return ax.find({ id: "x" })?.text; + }); + __testing__.runtime.extract((state) => { + const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; + return ax.findAll({ id: "x" }).map((element) => element.text); + }); + const values = __testing__.evaluateExtractors(); + assert.equal(readingOf(values, 0), "shadow"); + assert.deepEqual(readingOf(values, 1), ["shadow", "light"]); + }); }); From e62b08dffd08650889b945558e883da9425876cb Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:39:18 +0530 Subject: [PATCH 3/8] test(browser): compare ax.find across both hosts on one page --- test/browser/browser_test.go | 78 +++++++++++++++++++++ test/browser/testdata/find-order/index.html | 16 +++++ test/browser/testdata/find-order/spec.ts | 17 +++++ 3 files changed, 111 insertions(+) create mode 100644 test/browser/testdata/find-order/index.html create mode 100644 test/browser/testdata/find-order/spec.ts diff --git a/test/browser/browser_test.go b/test/browser/browser_test.go index 3cae9cd..1cab5d4 100644 --- a/test/browser/browser_test.go +++ b/test/browser/browser_test.go @@ -23,6 +23,7 @@ import ( "github.com/priyanshujain/sanderling/internal/bundler" "github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/driver/chrome" + "github.com/priyanshujain/sanderling/internal/hierarchy" chromerunner "github.com/priyanshujain/sanderling/internal/runner" "github.com/priyanshujain/sanderling/internal/trace" "github.com/priyanshujain/sanderling/internal/verifier" @@ -222,3 +223,80 @@ func TestBrowserUndefinedExtractorStaysUndefined(t *testing.T) { t.Fatalf("nothing was ever tapped, so the property above held vacuously; violations=%v", violations) } } + +// One page, one selector, two hosts, two answers. +// +// The V8 host resolves state.ax.find against the live DOM; the goja host +// resolves the same selector against the hierarchy dump. The fixture puts a +// shadow-hosted #x above a light-DOM #x, the one shape where the two walks can +// disagree, and on web it is V8's answer that reaches the properties. Each host +// is driven through its production path (EvaluateExtractors in the page, +// PushSnapshot over the dump) and neither is asked what the other said, so the +// comparison is evidence rather than an assertion about one of them. +func TestBrowserAxFindAgreesAcrossHosts(t *testing.T) { + server := httptest.NewServer(http.FileServer(http.Dir(testdataDir(t)))) + t.Cleanup(server.Close) + + gojaBundle, webBundle := bundleSpec(t, filepath.Join(testdataDir(t), "find-order", "spec.ts")) + + driverInstance := chrome.New() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = driverInstance.Terminate(ctx) + }) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := driverInstance.Launch(ctx, server.URL+"/find-order/", false, nil); err != nil { + t.Fatalf("launch: %v", err) + } + if err := driverInstance.InstallBundle(ctx, webBundle); err != nil { + t.Fatalf("install web bundle: %v", err) + } + readings, err := driverInstance.EvaluateExtractors(ctx) + if err != nil { + t.Fatalf("evaluate extractors in the page: %v", err) + } + fromV8 := string(readings[0]) + + dump, err := driverInstance.Hierarchy(ctx) + if err != nil { + t.Fatalf("hierarchy: %v", err) + } + tree, err := hierarchy.Parse(dump) + if err != nil { + t.Fatalf("parse hierarchy: %v", err) + } + verifierInstance, err := verifier.New( + verifier.WithSeed(fixtureSeed), + verifier.WithPlatform("web"), + ) + if err != nil { + t.Fatalf("verifier: %v", err) + } + if err := verifierInstance.Load(string(gojaBundle)); err != nil { + t.Fatalf("load spec: %v", err) + } + if err := verifierInstance.PushSnapshot(verifier.SnapshotInput{Tree: tree}); err != nil { + t.Fatalf("push snapshot: %v", err) + } + change, ok := verifierInstance.ChangedExtractors()["found"] + if !ok { + t.Fatal("the goja host recorded no reading for the found extractor") + } + fromGoja := string(change.Curr) + + // Pinned, not just compared: two hosts that both resolved nothing would + // agree on undefined and prove nothing about the walk. + if fromGoja != `"shadow"` { + t.Errorf("the goja host read %s off the dump, want the shadow-hosted %q", fromGoja, "shadow") + } + if fromV8 != fromGoja { + t.Fatalf( + "one page, one selector, two answers: the V8 host read %s and the goja host read %s", + fromV8, + fromGoja, + ) + } +} diff --git a/test/browser/testdata/find-order/index.html b/test/browser/testdata/find-order/index.html new file mode 100644 index 0000000..9e81425 --- /dev/null +++ b/test/browser/testdata/find-order/index.html @@ -0,0 +1,16 @@ + + + + +
+ light + + + diff --git a/test/browser/testdata/find-order/spec.ts b/test/browser/testdata/find-order/spec.ts new file mode 100644 index 0000000..854c367 --- /dev/null +++ b/test/browser/testdata/find-order/spec.ts @@ -0,0 +1,17 @@ +import { always, extract, taps } from "@sanderling/spec"; + +// Which of the two #x a selector means is the whole fixture. The reading is +// compared between the two hosts by TestBrowserAxFindAgreesAcrossHosts, which +// drives each host's production path and never asks one what the other said. +// +// Written in the "k:v" string grammar because that is the one form both hosts +// resolve the same way: the object form {id: "x"} reaches the goja host as a +// plain attribute filter, and a web hierarchy dump carries the id under +// resource-id, so it matches nothing there. +const found = extract((s) => s.ax.find("id:x")?.text).named("found"); + +const findsTheShadowMatch = always(() => found.current === "shadow"); + +export const properties = { findsTheShadowMatch }; + +export const actionsRoot = taps; From 0cef5bd1d3999bbdac1efc7b6b5f634084b22bd0 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:39:21 +0530 Subject: [PATCH 4/8] test(spec): name the shadow match in the grammar both hosts parse --- pkg/spec/test/web-runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 5316fc9..d325871 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -600,11 +600,11 @@ test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first" __testing__.extractors.length = 0; __testing__.runtime.extract((state) => { const ax = (state as { ax: { find(s: unknown): Record | undefined } }).ax; - return ax.find({ id: "x" })?.text; + return ax.find("id:x")?.text; }); __testing__.runtime.extract((state) => { const ax = (state as { ax: { findAll(s: unknown): Record[] } }).ax; - return ax.findAll({ id: "x" }).map((element) => element.text); + return ax.findAll("id:x").map((element) => element.text); }); const values = __testing__.evaluateExtractors(); assert.equal(readingOf(values, 0), "shadow"); From 56d6aa53f150ab1689b4643b61cdfcee586d717b Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:42:36 +0530 Subject: [PATCH 5/8] test: pin that a nested undefined does not survive the wire --- internal/verifier/extractor_encoding_test.go | 76 ++++++++++++++++++++ pkg/spec/test/web-runtime.test.ts | 24 +++++++ 2 files changed, 100 insertions(+) diff --git a/internal/verifier/extractor_encoding_test.go b/internal/verifier/extractor_encoding_test.go index a693dbf..40b9a32 100644 --- a/internal/verifier/extractor_encoding_test.go +++ b/internal/verifier/extractor_encoding_test.go @@ -177,3 +177,79 @@ func compactJSON(t *testing.T, source string) string { } return compact.String() } + +// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire pins the one reading +// shape the two hosts do NOT encode alike, rather than hiding it. +// +// JSON has no undefined, so the page loses the whole key (asserted in +// pkg/spec/test/web-runtime.test.ts) while goja writes null. goja cannot mirror +// the drop: Export reports an undefined member and a null member identically as +// nil, so dropping those keys here would drop the genuine nulls the page keeps. +// Mirroring the other way, by writing null on the page, would break the one +// thing that does agree. Carrying the member across takes a wire format that +// can express undefined, which is a change to every layer that parses a reading +// and to the replay UI that renders one. +// +// So the guarantee is narrower than "the same object": both hosts answer +// undefined when a property READS the member. Key presence (`in`, Object.keys) +// is not part of it, and this test says so out loud, so closing the gap has to +// be a deliberate change to both hosts at once. +func TestExtractorEncoding_NestedUndefinedIsNotOnTheWire(t *testing.T) { + const reading = `({ absent: undefined, empty: null, present: 1 })` + const fromGoja = `{"absent":null,"empty":null,"present":1}` + // What the page sends for the same getter, with the key gone. + const fromWeb = `{"empty":null,"present":1}` + + if got := encodeSpecValue(t, reading); got != fromGoja { + t.Errorf("goja encoded the reading as %s, want %s", got, fromGoja) + } + + native := newVerifier(t) + mustLoad(t, native, "__sanderling__.extract(state => "+reading+", \"value\");\nglobalThis.properties = {};") + if err := native.PushSnapshot(SnapshotInput{}); err != nil { + t.Fatal(err) + } + + web := newVerifier(t) + mustLoad(t, web, "__sanderling__.extract(state => null, \"value\");\nglobalThis.properties = {};") + if err := web.PushSnapshot(SnapshotInput{}); err != nil { + t.Fatal(err) + } + if _, err := web.OverrideExtractorValues(map[int]json.RawMessage{0: json.RawMessage(fromWeb)}); err != nil { + t.Fatal(err) + } + + for _, probe := range []struct { + expression string + native bool + web bool + }{ + {"reading.absent === undefined", true, true}, + {"reading.empty === null", true, true}, + {"reading.present === 1", true, true}, + // The half that does not survive the wire. + {`"absent" in reading`, true, false}, + } { + if got := evaluateAgainstReading(t, native, probe.expression); got != probe.native { + t.Errorf("goja host: %s is %v, want %v", probe.expression, got, probe.native) + } + if got := evaluateAgainstReading(t, web, probe.expression); got != probe.web { + t.Errorf("web host: %s is %v, want %v", probe.expression, got, probe.web) + } + } +} + +// evaluateAgainstReading answers a boolean expression over the value a property +// would read out of the first extractor, which is where the two hosts have to +// agree. +func evaluateAgainstReading(t *testing.T, verifier *Verifier, expression string) bool { + t.Helper() + if err := verifier.runtime.GlobalObject().Set("reading", verifier.extractors[0].currentValue); err != nil { + t.Fatal(err) + } + value, err := verifier.runtime.RunString(expression) + if err != nil { + t.Fatalf("evaluate %s: %v", expression, err) + } + return value.ToBoolean() +} diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index d325871..105975b 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -611,3 +611,27 @@ test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first" assert.deepEqual(readingOf(values, 1), ["shadow", "light"]); }); }); + +// 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, +// and it cannot do otherwise, because an exported goja object reports undefined +// and null identically, so dropping those keys there would drop the genuine +// nulls this host keeps. Carrying the member across would take a wire format +// that can express undefined. +// +// What both hosts DO agree on is the member's value: reading it answers +// undefined either way, and that is the guarantee a property may rely on. Key +// presence (`in`, Object.keys) is not. +// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire in +// internal/verifier/extractor_encoding_test.go pins the other half. +test("a nested undefined leaves the page as a dropped key, a nested null does not", () => { + __testing__.extractors.length = 0; + __testing__.runtime.extract(() => ({ absent: undefined, empty: null, present: 1 })); + let wire = ""; + withState(() => { + // Exactly what extractorScript in internal/driver/chrome/driver.go sends. + wire = JSON.stringify(__testing__.evaluateExtractors()); + }); + assert.equal(wire, `{"0":{"value":{"empty":null,"present":1}}}`); +}); From ad63c5eb74b2bc673934afea83c37dca4046cb09 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:50:55 +0530 Subject: [PATCH 6/8] fix(hierarchy): object selectors resolve by the same rule as string ones --- internal/hierarchy/hierarchy.go | 14 +++-- internal/hierarchy/hierarchy_test.go | 80 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index c54a052..1f1e9b4 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -11,7 +11,8 @@ // descPrefix: - starts-with on content-desc / accessibilityText // // Object selectors (multi-attribute AND, element-scoped or global): -// { attr: value, ... } - all key/value pairs must match; substring / boolean semantics +// { attr: value, ... } - all key/value pairs must match, each key resolved by +// the same rule its string form above uses // // Path queries (global scan only, string form): // > > ... - each segment matched within subtree of previous match @@ -156,10 +157,17 @@ func matchAttr(element *Element, attr, value string) bool { return false } -// matchSelector returns true when all filters in sel match the element (AND semantics). +// matchSelector returns true when all filters in sel match the element (AND +// semantics). Each filter goes through match, the same rule the string form +// resolves a "kind:value" segment by, so {id: "Submit"} and "id:Submit" can +// never resolve to different elements. Applying matchAttr directly here made +// the object form skip the kind arms entirely: id, desc and descPrefix name no +// attribute any producer writes, so those keys matched NOTHING through an +// object selector while the string form matched, and every property over the +// missing element passed vacuously. func matchSelector(element *Element, sel Selector) bool { for _, f := range sel.Filters { - if !matchAttr(element, f.Attr, f.Value) { + if !match(element, f.Attr, f.Value) { return false } } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 91abd4c..395065f 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -1035,3 +1035,83 @@ func TestTreeTransitional(t *testing.T) { t.Error("nil tree must not be flagged as transitional") } } + +// selectorFormsDump carries one node per id shape a real dump produces, plus +// nodes carrying a description in the ", " form the desc rule knows about and a +// text the text rule matches on a substring. +const selectorFormsDump = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"}, + "children": [ + {"attributes": {"resource-id": "BareThing", "bounds": "[0,0,100,50]"}, "children": []}, + {"attributes": {"resource-id": "com.example.app:id/AndroidThing", "bounds": "[0,50,100,100]"}, + "children": []}, + {"attributes": {"accessibilityIdentifier": "IosThing", "bounds": "[0,100,100,150]"}, + "children": []}, + {"attributes": {"resource-id": "Described", "content-desc": "Save, button", "bounds": "[0,150,100,200]"}, + "children": []}, + {"attributes": {"resource-id": "Labelled", "text": "Total balance", "bounds": "[0,200,100,250]"}, + "children": []} + ] +}` + +// TestSelectorFormsResolveTheSameElement holds the two selector forms a spec can +// write to ONE rule per key. A spec reaches these through state.ax.find: a +// string goes to FindNode, an object to FindBySelector, and the two ran +// different matchers. `id` has a kind arm that knows an Android resource id is +// package-qualified (com.example.app:id/Thing) and that a spec names the bare +// tail; the object form had no such arm and looked for a literal `id` attribute +// no producer writes, so {id: "Thing"} silently matched nothing on every +// platform while "id:Thing" matched. `desc` and `descPrefix` had the same +// split. A selector that resolves nothing makes every property over it +// vacuously true, which is the failure that reports a green run while checking +// nothing. +func TestSelectorFormsResolveTheSameElement(t *testing.T) { + tree, err := Parse(selectorFormsDump) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + key string + value string + want string + }{ + {"id", "BareThing", "BareThing"}, + // A spec names the tail; an Android dump carries the package prefix. + {"id", "AndroidThing", "com.example.app:id/AndroidThing"}, + {"id", "com.example.app:id/AndroidThing", "com.example.app:id/AndroidThing"}, + {"id", "IosThing", "IosThing"}, + {"desc", "Save, button", "Described"}, + // The ", " form an accessibility label takes when a role is appended. + {"desc", "Save", "Described"}, + {"descPrefix", "Sav", "Described"}, + {"text", "Total", "Labelled"}, + {"resource-id", "BareThing", "BareThing"}, + {"testTag", "IosThing", "IosThing"}, + } { + t.Run(test.key+":"+test.value, func(t *testing.T) { + stringForm := test.key + ":" + test.value + fromString := tree.FindNode(stringForm) + if fromString == nil { + t.Fatalf("the string form %q resolved nothing", stringForm) + } + if fromString.ResourceID != test.want { + t.Fatalf("the string form resolved %q, want %q", fromString.ResourceID, test.want) + } + fromObject := tree.Root.FindBySelector( + Selector{Filters: []AttrFilter{{Attr: test.key, Value: test.value}}}, + ) + if fromObject == nil { + t.Fatalf( + "the object form {%s: %q} resolved nothing while %q resolved %q", + test.key, test.value, stringForm, fromString.ResourceID, + ) + } + if fromObject != fromString { + t.Errorf( + "one selector, two answers: {%s: %q} resolved %q and %q resolved %q", + test.key, test.value, fromObject.ResourceID, stringForm, fromString.ResourceID, + ) + } + }) + } +} From 4324573d97e3cbb9a971c7c36f4ac58502b8b706 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:51:43 +0530 Subject: [PATCH 7/8] test(verifier): both ax.find selector forms resolve the same element --- internal/verifier/ax_integration_test.go | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index 97fd596..6a3589b 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -2,6 +2,7 @@ package verifier import ( "os" + "strconv" "testing" "github.com/priyanshujain/sanderling/internal/hierarchy" @@ -99,3 +100,77 @@ func TestStateAxFindWorks(t *testing.T) { t.Fatalf("findAll count = %d, want 1", count) } } + +// axSelectorFormsTree carries one node per id shape a dump produces: the bare +// tag Compose and the web driver emit, the package-qualified resource id +// Android emits, and the iOS accessibility identifier. +const axSelectorFormsTree = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"}, + "children": [ + {"attributes": {"resource-id": "BareThing", "text": "bare", "bounds": "[0,0,100,50]"}, + "children": []}, + {"attributes": {"resource-id": "com.example.app:id/AndroidThing", "text": "android", + "bounds": "[0,50,100,100]"}, "children": []}, + {"attributes": {"accessibilityIdentifier": "IosThing", "text": "ios", + "bounds": "[0,100,100,150]"}, "children": []} + ] +}` + +// TestStateAxSelectorFormsAgree drives both selector forms a spec can write +// through state.ax.find and holds them to the same element. The two forms +// dispatch to different lookups (findNodeFromJS sends a string to FindNode and +// an object to FindBySelector), and the object one used to skip the id rule +// that knows an Android resource id is package-qualified, so a spec that wrote +// ax.find({id: "AddAccountSubmit"}) got undefined on Android and every property +// reading it passed while checking nothing. +func TestStateAxSelectorFormsAgree(t *testing.T) { + tree, err := hierarchy.Parse(axSelectorFormsTree) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + value string + want string + }{ + {"BareThing", "bare"}, + {"AndroidThing", "android"}, + {"com.example.app:id/AndroidThing", "android"}, + {"IosThing", "ios"}, + } { + t.Run(test.value, func(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.fromObject = __sanderling__.extract( + state => state.ax.find({ id: `+strconv.Quote(test.value)+` })?.text, "fromObject"); + globalThis.fromString = __sanderling__.extract( + state => state.ax.find("id:" + `+strconv.Quote(test.value)+`)?.text, "fromString"); + globalThis.properties = {}; + `) + if err := verifier.PushSnapshot(SnapshotInput{Tree: tree}); err != nil { + t.Fatal(err) + } + fromObject := readCurrent(t, verifier, "fromObject") + fromString := readCurrent(t, verifier, "fromString") + if fromString != test.want { + t.Fatalf(`ax.find("id:%s") read %v, want %q`, test.value, fromString, test.want) + } + if fromObject != fromString { + t.Errorf( + `one selector, two answers: ax.find({id: %q}) read %v and ax.find("id:%s") read %v`, + test.value, fromObject, test.value, fromString, + ) + } + }) + } +} + +// readCurrent returns a named extractor's current value, or nil when the getter +// returned undefined, which is what an unresolved selector produces. +func readCurrent(t *testing.T, verifier *Verifier, name string) any { + t.Helper() + handle := verifier.runtime.GlobalObject().Get(name) + if handle == nil { + t.Fatalf("%s is not defined", name) + } + return handle.ToObject(verifier.runtime).Get("current").Export() +} From 047c53a2e74d1dc7f599af539d0b57ccf1c728e5 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:52:43 +0530 Subject: [PATCH 8/8] test(browser): the cross-host fixture uses the object selector form --- test/browser/browser_test.go | 14 ++++++++++---- test/browser/testdata/find-order/spec.ts | 10 +++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/test/browser/browser_test.go b/test/browser/browser_test.go index 1cab5d4..b94732d 100644 --- a/test/browser/browser_test.go +++ b/test/browser/browser_test.go @@ -281,11 +281,17 @@ func TestBrowserAxFindAgreesAcrossHosts(t *testing.T) { if err := verifierInstance.PushSnapshot(verifier.SnapshotInput{Tree: tree}); err != nil { t.Fatalf("push snapshot: %v", err) } - change, ok := verifierInstance.ChangedExtractors()["found"] - if !ok { - t.Fatal("the goja host recorded no reading for the found extractor") + if count := verifierInstance.ExtractorCount(); count != 1 { + t.Fatalf("the goja host registered %d extractors, want the fixture's 1", count) + } + // ChangedExtractors omits an extractor whose reading is null and unchanged, + // which is exactly what a selector resolving nothing produces. With the + // count checked above, an absent entry is a null reading and not a missing + // extractor, so reporting it as null names the real failure. + fromGoja := "null" + if change, ok := verifierInstance.ChangedExtractors()["found"]; ok { + fromGoja = string(change.Curr) } - fromGoja := string(change.Curr) // Pinned, not just compared: two hosts that both resolved nothing would // agree on undefined and prove nothing about the walk. diff --git a/test/browser/testdata/find-order/spec.ts b/test/browser/testdata/find-order/spec.ts index 854c367..2145321 100644 --- a/test/browser/testdata/find-order/spec.ts +++ b/test/browser/testdata/find-order/spec.ts @@ -4,11 +4,11 @@ import { always, extract, taps } from "@sanderling/spec"; // compared between the two hosts by TestBrowserAxFindAgreesAcrossHosts, which // drives each host's production path and never asks one what the other said. // -// Written in the "k:v" string grammar because that is the one form both hosts -// resolve the same way: the object form {id: "x"} reaches the goja host as a -// plain attribute filter, and a web hierarchy dump carries the id under -// resource-id, so it matches nothing there. -const found = extract((s) => s.ax.find("id:x")?.text).named("found"); +// Written in the object form on purpose. It used to reach the goja host as a +// plain attribute filter looking for an `id` attribute a dump never carries +// (the web dump files the DOM id under resource-id), so it resolved nothing +// there while the V8 host resolved it against the live DOM. +const found = extract((s) => s.ax.find({ id: "x" })?.text).named("found"); const findsTheShadowMatch = always(() => found.current === "shadow");