mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +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
|
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
||||||
//
|
//
|
||||||
// Object selectors (multi-attribute AND, element-scoped or global):
|
// 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):
|
// Path queries (global scan only, string form):
|
||||||
// <sel> > <sel> > ... - each segment matched within subtree of previous match
|
// <sel> > <sel> > ... - each segment matched within subtree of previous match
|
||||||
@@ -156,10 +157,17 @@ func matchAttr(element *Element, attr, value string) bool {
|
|||||||
return false
|
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 {
|
func matchSelector(element *Element, sel Selector) bool {
|
||||||
for _, f := range sel.Filters {
|
for _, f := range sel.Filters {
|
||||||
if !matchAttr(element, f.Attr, f.Value) {
|
if !match(element, f.Attr, f.Value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1035,3 +1035,83 @@ func TestTreeTransitional(t *testing.T) {
|
|||||||
t.Error("nil tree must not be flagged as transitional")
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
@@ -99,3 +100,77 @@ func TestStateAxFindWorks(t *testing.T) {
|
|||||||
t.Fatalf("findAll count = %d, want 1", count)
|
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()
|
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
|
// (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:
|
// 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
|
// 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
|
// resolve a testTag. Matches come back in the order expandShadowContent walks
|
||||||
// order. XPath has no equivalent, so `text:` selectors stop at the boundary.
|
// 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[] {
|
function deepQueryAll(selector: string, root: ParentNode): Element[] {
|
||||||
const found: Element[] = [];
|
const found: Element[] = [];
|
||||||
const visit = (scope: ParentNode): void => {
|
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>("*"))) {
|
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
|
||||||
|
if (matched.has(element)) found.push(element);
|
||||||
if (element.shadowRoot) visit(element.shadowRoot);
|
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
|
// A small DOM the web runtime can be driven over, shared by the web runtime's
|
||||||
// runtime's own tests and the cross-host eligibility test. The host asks the
|
// own tests and the cross-host eligibility test.
|
||||||
// document for three things -- every element, the tappable set, the editable set
|
//
|
||||||
// -- and reads geometry, `disabled` and the scroll extents off each element, so
|
// It is a fake, but the structure is real: elements nest, a host owns a shadow
|
||||||
// that is all a fake has to answer.
|
// 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";
|
import { __testing__ } from "../src/web-runtime.ts";
|
||||||
|
|
||||||
@@ -23,23 +36,45 @@ export interface FakeElementSpec {
|
|||||||
label?: string;
|
label?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
// clickable/editable place the element in the selector sets the host queries;
|
// text is what an ax element handle reports as `text`, the same field the
|
||||||
// the fake answers those queries directly rather than matching CSS.
|
// 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;
|
clickable?: boolean;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
// overflows makes the element's content taller than its box, which is how the
|
// overflows makes the element's content taller than its box, which is how the
|
||||||
// host decides an element is scrollable.
|
// host decides an element is scrollable.
|
||||||
overflows?: boolean;
|
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;
|
tagName: string;
|
||||||
type: string;
|
type: string;
|
||||||
isContentEditable: boolean;
|
isContentEditable: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
|
className: string;
|
||||||
|
textContent: string;
|
||||||
dataset: Record<string, string | undefined>;
|
dataset: Record<string, string | undefined>;
|
||||||
|
parentElement: FakeElement | null;
|
||||||
|
children: FakeElement[];
|
||||||
|
shadowRoot: FakeRoot | null;
|
||||||
getAttribute(name: string): string | null;
|
getAttribute(name: string): string | null;
|
||||||
|
querySelectorAll(selector: string): FakeElement[];
|
||||||
scrollHeight: number;
|
scrollHeight: number;
|
||||||
clientHeight: number;
|
clientHeight: number;
|
||||||
scrollWidth: number;
|
scrollWidth: number;
|
||||||
@@ -56,15 +91,26 @@ export interface FakeElement extends FakeElementSpec {
|
|||||||
|
|
||||||
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||||
const editable = spec.editable ?? false;
|
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,
|
...spec,
|
||||||
tagName: spec.tag.toUpperCase(),
|
tagName: spec.tag.toUpperCase(),
|
||||||
type: spec.tag === "input" ? "text" : "",
|
type: spec.tag === "input" ? "text" : "",
|
||||||
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||||
id: spec.id ?? "",
|
id: spec.id ?? "",
|
||||||
|
className: attributes.class ?? "",
|
||||||
|
textContent: spec.text ?? "",
|
||||||
dataset: { testid: spec.testid },
|
dataset: { testid: spec.testid },
|
||||||
getAttribute: (name: string) =>
|
parentElement: null,
|
||||||
({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? 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,
|
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||||
clientHeight: spec.height,
|
clientHeight: spec.height,
|
||||||
scrollWidth: spec.width,
|
scrollWidth: spec.width,
|
||||||
@@ -78,27 +124,170 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
|
|||||||
bottom: spec.y + spec.height,
|
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
|
// A shadow root's children have no parentElement, as in a real DOM, so a
|
||||||
// `elements`, resets the host's per-tick cache, and restores the real document
|
// descendant selector cannot reach across the boundary from either side.
|
||||||
// afterwards.
|
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 {
|
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
|
||||||
const global = globalThis as Record<string, unknown>;
|
const global = globalThis as Record<string, unknown>;
|
||||||
const original = global.document;
|
const originalDocument = global.document;
|
||||||
const answers: Record<string, FakeElement[]> = {
|
const originalWindow = global.window;
|
||||||
"*": elements,
|
const document: FakeRoot = fakeRoot(elements);
|
||||||
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
|
global.document = document;
|
||||||
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
|
global.window = {};
|
||||||
};
|
|
||||||
global.document = {
|
|
||||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
|
||||||
};
|
|
||||||
__testing__.resetTargetCache();
|
__testing__.resetTargetCache();
|
||||||
try {
|
try {
|
||||||
run();
|
run();
|
||||||
} finally {
|
} finally {
|
||||||
__testing__.resetTargetCache();
|
__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");
|
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
|
// 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
|
// 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", () => {
|
test("queryTargets caches within a tick until reset", () => {
|
||||||
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
||||||
withFakeDocument([button], () => {
|
withFakeDocument([button], () => {
|
||||||
@@ -473,28 +523,23 @@ test("selectorTag renders the selector shapes the goja host renders", () => {
|
|||||||
// accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}]))
|
// accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}]))
|
||||||
// were empty on every web step and the properties over them checked nothing.
|
// were empty on every web step and the properties over them checked nothing.
|
||||||
test("ax.findAll resolves a selector path segment by segment", () => {
|
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 card = (id: string, y: number): FakeElementSpec => ({
|
||||||
const node = (id: string, answers: Record<string, unknown[]> = {}) => ({
|
tag: "div", x: 0, y, width: 10, height: 10, testid: "AccountCard", text: id,
|
||||||
id,
|
});
|
||||||
tagName: "DIV",
|
// The stray card is outside HomeScreen, so a document-wide sweep for the
|
||||||
className: "",
|
// second segment picks it up and the scoping assertion below fails.
|
||||||
textContent: id,
|
const page = fakeElement({
|
||||||
dataset: {},
|
tag: "div", x: 0, y: 0, width: 100, height: 100,
|
||||||
getAttribute: () => null,
|
children: [
|
||||||
getBoundingClientRect: () => rect,
|
{
|
||||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
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>;
|
withFakeDocument([page], () => {
|
||||||
const originalDocument = g.document;
|
|
||||||
const originalWindow = g.window;
|
|
||||||
g.document = { querySelectorAll: (selector: string) => (selector === screenCss ? [home] : []) };
|
|
||||||
g.window = {};
|
|
||||||
try {
|
|
||||||
__testing__.extractors.length = 0;
|
__testing__.extractors.length = 0;
|
||||||
__testing__.runtime.extract((state) => {
|
__testing__.runtime.extract((state) => {
|
||||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
|
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
|
// Scoped to the head match: the cards come from the HomeScreen node, not
|
||||||
// from a document-wide sweep for AccountCard.
|
// from a document-wide sweep for AccountCard.
|
||||||
assert.deepEqual(readingOf(values, 0), ["first", "second"]);
|
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", () => {
|
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 = fakeElement({
|
||||||
const submit = {
|
tag: "div", x: 0, y: 0, width: 10, height: 10, id: "TxnSubmit", text: "Submit",
|
||||||
id: "TxnSubmit",
|
});
|
||||||
tagName: "DIV",
|
withFakeDocument([submit], () => {
|
||||||
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 {
|
|
||||||
__testing__.extractors.length = 0;
|
__testing__.extractors.length = 0;
|
||||||
__testing__.runtime.extract((state) => {
|
__testing__.runtime.extract((state) => {
|
||||||
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
|
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.
|
// reference would hand the array INDEX to the runtime as the selector.
|
||||||
const all = readingOf(values, 1) as Record<string, unknown>[];
|
const all = readingOf(values, 1) as Record<string, unknown>[];
|
||||||
assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit");
|
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/bundler"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
chromerunner "github.com/priyanshujain/sanderling/internal/runner"
|
chromerunner "github.com/priyanshujain/sanderling/internal/runner"
|
||||||
"github.com/priyanshujain/sanderling/internal/trace"
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
"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)
|
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