//go:build browser package chrome import ( "context" "encoding/json" "path/filepath" "slices" "strings" "testing" "github.com/chromedp/chromedp" "github.com/priyanshujain/sanderling/internal/bundler" "github.com/priyanshujain/sanderling/internal/hierarchy" ) // One page, two selector matchers. // // A selector means whatever the runtime resolving it decides. The goja host // resolves it against the hierarchy dump through internal/hierarchy; the V8 host // resolves it against the live DOM through pkg/spec/src/web-runtime.ts. Nothing // makes the two agree, so a spec that targets a list of rows can act on them on // one platform and find nothing on the other, and finding nothing is silent: the // generator yields no action and the run still passes. // // This test starts from one real page in a real browser and asks both matchers // the same questions. func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) { d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html") dump, err := d.Hierarchy(ctx) if err != nil { t.Fatalf("Hierarchy: %v", err) } tree, err := hierarchy.Parse(dump) if err != nil { t.Fatalf("parse hierarchy: %v", err) } installSelectorProbe(ctx, t, d) // want is stated here rather than derived, so a matcher that stops matching // on both sides at once cannot pass this by agreeing on nothing. cases := []struct { name string selector string object hierarchy.Selector want []string }{ { name: "idPrefix", selector: "idPrefix:customer_row_", object: objectSelector("idPrefix", "customer_row_"), want: []string{"customer_row_a1", "customer_row_b2"}, }, { name: "idPrefix spanning the container", selector: "idPrefix:customer_", object: objectSelector("idPrefix", "customer_"), want: []string{"customer_list", "customer_row_a1", "customer_row_b2"}, }, { name: "idPrefix matching nothing", selector: "idPrefix:invoice_row_", object: objectSelector("idPrefix", "invoice_row_"), want: nil, }, { name: "id", selector: "id:summary_card", object: objectSelector("id", "summary_card"), want: []string{"summary_card"}, }, { name: "descPrefix", selector: "descPrefix:customer_row_", object: objectSelector("descPrefix", "customer_row_"), want: []string{"customer_row_a1", "customer_row_b2"}, }, { name: "desc", selector: "desc:customer_row_a1", object: objectSelector("desc", "customer_row_a1"), want: []string{"customer_row_a1"}, }, { name: "data-testid", selector: "data-testid:customer-row", object: objectSelector("data-testid", "customer-row"), want: []string{"customer_row_a1", "customer_row_b2"}, }, { name: "raw attribute, matched on a substring", selector: "data-state:sent", object: objectSelector("data-state", "sent"), want: []string{"status_badge"}, }, { name: "class", selector: "class:status", object: objectSelector("class", "status"), want: []string{"nested_row", "nested_badge"}, }, { // className is the DOM property name for the same attribute, and // the two names have to name the same elements: the web runtime // compiles both to the same class query, while the dump carries // the fact under `class` alone and no alias reached it, so this // key matched the row and the badge on one host and NOTHING on the // other. The key is accepted, so no unknown-key error fires, and a // property over the missing element passes having checked nothing. name: "className", selector: "className:status", object: objectSelector("className", "status"), want: []string{"nested_row", "nested_badge"}, }, { // Every name for the accessible label has to name one element. Four // of the six reached it on one host only: label and // accessibilityLabel aliased onto accessibilityText alone, which // the ios companion writes and this dump does not, and alias // expansion is one level; ariaLabel and contentDescription aliased // onto nothing. accessibilityText was the mirror image, resolving // against the dump and reaching no DOM attribute of that name. name: "ariaLabel", selector: "ariaLabel:login_email", object: objectSelector("ariaLabel", "login_email"), want: []string{"login_email"}, }, { name: "contentDescription", selector: "contentDescription:login_email", object: objectSelector("contentDescription", "login_email"), want: []string{"login_email"}, }, { name: "label", selector: "label:login_email", object: objectSelector("label", "login_email"), want: []string{"login_email"}, }, { name: "accessibilityLabel", selector: "accessibilityLabel:login_email", object: objectSelector("accessibilityLabel", "login_email"), want: []string{"login_email"}, }, { name: "accessibilityText", selector: "accessibilityText:login_email", object: objectSelector("accessibilityText", "login_email"), want: []string{"login_email"}, }, { // The two ios names for the identifier, which resolved against the // dump through an alias and against no DOM attribute at all. name: "identifier", selector: "identifier:summary_card", object: objectSelector("identifier", "summary_card"), want: []string{"summary_card"}, }, { name: "accessibilityIdentifier", selector: "accessibilityIdentifier:summary_card", object: objectSelector("accessibilityIdentifier", "summary_card"), want: []string{"summary_card"}, }, { // The ios name for the class, the same way round. name: "elementType", selector: "elementType:status", object: objectSelector("elementType", "status"), want: []string{"nested_row", "nested_badge"}, }, { // Compose for Web writes a test tag as data-testid. testTag reached // the three identifier keys and not that one, and testID reached // nothing at all, so both named every row of the list on web and no // element here. name: "testID", selector: "testID:customer-row", object: objectSelector("testID", "customer-row"), want: []string{"customer_row_a1", "customer_row_b2"}, }, { name: "testTag", selector: "testTag:customer-row", object: objectSelector("testTag", "customer-row"), want: []string{"customer_row_a1", "customer_row_b2"}, }, { // A custom element's tag name holds a real tag name inside it, so a // substring rule answered tag:li with and tag:a with // : the container, not the row the author named. name: "tag against a page of custom elements", selector: "tag:li", object: objectSelector("tag", "li"), want: []string{"todo_1", "todo_2"}, }, { name: "tag naming the custom element itself", selector: "tag:todo-list", object: objectSelector("tag", "todo-list"), want: []string{"todo_list"}, }, { name: "tag naming an anchor", selector: "tag:a", object: objectSelector("tag", "a"), want: []string{"todo_link"}, }, { // secure names no attribute the markup writes: both producers derive // it from the field's type. Matched as a raw attribute it reached // nothing here while resolving fine against the dump, and `secure` // being an accepted key meant no unknown-key error said so. name: "secure", selector: "secure:true", object: objectSelector("secure", "true"), want: []string{"login_password"}, }, { // false is every editable field that is not a password entry, not // everything that is not one: a control that is no field at all // reports null, the way android reports null for everything, and // answers to neither value. name: "not secure", selector: "secure:false", object: objectSelector("secure", "false"), want: []string{ "login_email", "login_note", "login_terms", "hint_phone", "hint_search", "hint_amount", "hint_code", }, }, { // hintText is the accessible-name ladder both producers derive: // the field's aria-label, the