mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 03:27:10 +00:00
Merge branch 'spec-parity-holes' into trial-merge
This commit is contained in:
commit
79dbc471b8
10 files changed
+709
-75
No files matched your search
@@ -11,7 +11,8 @@
|
||||
// descPrefix:<prefix> - 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):
|
||||
// <sel> > <sel> > ... - 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Element>(Array.from(scope.querySelectorAll(selector)));
|
||||
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
|
||||
if (matched.has(element)) found.push(element);
|
||||
if (element.shadowRoot) visit(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<string, string>;
|
||||
// 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 <input> 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<FakeElementSpec, "children" | "shadow"> {
|
||||
tagName: string;
|
||||
type: string;
|
||||
isContentEditable: boolean;
|
||||
id: string;
|
||||
className: string;
|
||||
textContent: string;
|
||||
dataset: Record<string, string | undefined>;
|
||||
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<string, string> = { ...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<string, unknown>;
|
||||
const original = global.document;
|
||||
const answers: Record<string, FakeElement[]> = {
|
||||
"*": 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;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ test("installRuntime defined the host-invoked globals", () => {
|
||||
});
|
||||
|
||||
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
|
||||
type FakeElementSpec = Parameters<typeof fakeElement>[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<string, unknown[]> = {}) => ({
|
||||
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<string, unknown>;
|
||||
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<string, unknown>[] } }).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<string, unknown>;
|
||||
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<string, unknown> | undefined } }).ax;
|
||||
@@ -546,8 +575,63 @@ 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<string, unknown>[];
|
||||
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<string, unknown> | undefined } }).ax;
|
||||
return ax.find("id:x")?.text;
|
||||
});
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).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"]);
|
||||
});
|
||||
});
|
||||
|
||||
// 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}}}`);
|
||||
});
|
||||
@@ -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,86 @@ 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<!-- Two elements share the id, one inside a shadow root on the mount and
|
||||
one later in the light DOM. The hierarchy dump orders a host's shadow
|
||||
children before its light ones, so a pre-order search there reaches the
|
||||
shadow one first; a light-DOM sweep that only descends afterwards
|
||||
reaches the other. The page exists to make the two walks disagree. -->
|
||||
<div id="mount" style="width: 200px; height: 80px"></div>
|
||||
<span id="x">light</span>
|
||||
<script>
|
||||
document.getElementById("mount").attachShadow({ mode: "open" }).innerHTML =
|
||||
'<span id="x">shadow</span>';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+17
@@ -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 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");
|
||||
|
||||
export const properties = { findsTheShadowMatch };
|
||||
|
||||
export const actionsRoot = taps;
|
||||
Reference in new issue
Block a user