Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/js/modules/k6/browser/browser/locator_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
func mapLocator(vu moduleVU, lo *common.Locator) mapping {
rt := vu.Runtime()
return mapping{
"__locator": lo,
"all": func() *sobek.Promise {
return promise(vu, func() (any, error) {
all, err := lo.All()
Expand Down Expand Up @@ -318,6 +319,19 @@ func mapLocator(vu moduleVU, lo *common.Locator) mapping {
ml := mapLocator(vu, lo.Nth(nth))
return rt.ToValue(ml).ToObject(rt)
},
"or": func(locatorObj sobek.Value) (*sobek.Object, error) {
obj := locatorObj.ToObject(rt)
innerLocVal := obj.Get("__locator")
if k6common.IsNullish(innerLocVal) {
return nil, errors.New("internal locator is missing")
}
innerLoc, ok := innerLocVal.Export().(*common.Locator)
if !ok {
return nil, errors.New("internal locator has invalid type")
}
ml := mapLocator(vu, lo.Or(innerLoc))
return rt.ToValue(ml).ToObject(rt), nil
},
"textContent": func(opts sobek.Value) (*sobek.Promise, error) {
copts := common.NewFrameTextContentOptions(lo.Timeout())
if err := copts.Parse(vu.Context(), opts); err != nil {
Expand Down
50 changes: 41 additions & 9 deletions internal/js/modules/k6/browser/common/js/injected_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -1767,12 +1767,39 @@ class InjectedScript {
};
}

_querySelectorRecursively(roots, selector, index, queryCache) {
_querySelectorRecursively(roots, selector, index, queryCache, originalRoot) {
if (index === selector.parts.length) {
return roots;
}

const part = selector.parts[index];
if (part.name === "internal:or") {
const innerSelector = JSON.parse(part.body);
const orRoots = this._querySelectorRecursively(
[{ element: originalRoot, capture: undefined }],
innerSelector,
0,
new k6BrowserNative.Map(),
originalRoot
);
const merged = [...roots, ...orRoots];
const seen = new k6BrowserNative.Set();
const unique = [];
for (const item of merged) {
if (!seen.has(item.element)) {
seen.add(item.element);
unique.push(item);
}
}
unique.sort((a, b) => {
const pos = a.element.compareDocumentPosition(b.element);
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
return this._querySelectorRecursively(unique, selector, index + 1, queryCache, originalRoot);
}

if (part.name === "nth") {
let filtered = [];
if (part.body === "0") {
Expand All @@ -1798,7 +1825,8 @@ class InjectedScript {
filtered,
selector,
index + 1,
queryCache
queryCache,
originalRoot
);
}

Expand Down Expand Up @@ -1834,26 +1862,28 @@ class InjectedScript {
}

// Explore the Shadow DOM recursively.
const shadowResults = this._exploreShadowDOM(root.element, selector, index, queryCache, capture);
const shadowResults = this._exploreShadowDOM(root.element, selector, index, queryCache, capture, originalRoot);
result.push(...shadowResults);
}

return this._querySelectorRecursively(
result,
selector,
index + 1,
queryCache
queryCache,
originalRoot
);
}

_exploreShadowDOM(root, selector, index, queryCache, capture) {
_exploreShadowDOM(root, selector, index, queryCache, capture, originalRoot) {
let result = [];
if (root.shadowRoot) {
const shadowRootResults = this._querySelectorRecursively(
[{ element: root.shadowRoot, capture }],
selector,
index,
queryCache
queryCache,
originalRoot
);
result = result.concat(shadowRootResults);
}
Expand All @@ -1862,7 +1892,7 @@ class InjectedScript {

for (let i = 0; i < root.children.length; i++) {
const childElement = root.children[i];
result = result.concat(this._exploreShadowDOM(childElement, selector, index, queryCache, capture));
result = result.concat(this._exploreShadowDOM(childElement, selector, index, queryCache, capture, originalRoot));
}

return result;
Expand Down Expand Up @@ -2244,7 +2274,8 @@ class InjectedScript {
[{ element: root, capture: undefined }],
selector,
0,
new k6BrowserNative.Map()
new k6BrowserNative.Map(),
root
);
if (strict && result.length > 1) {
throw "error:strictmodeviolation";
Expand All @@ -2263,7 +2294,8 @@ class InjectedScript {
[{ element: root, capture: undefined }],
selector,
0,
new k6BrowserNative.Map()
new k6BrowserNative.Map(),
root
);
const set = new k6BrowserNative.Set();
for (const r of result) {
Expand Down
9 changes: 9 additions & 0 deletions internal/js/modules/k6/browser/common/locator.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,15 @@ func (l *Locator) Locator(selector string, opts *LocatorOptions) *Locator {
return NewLocator(l.ctx, opts, l.selector+" >> "+selector, l.frame, l.log)
}

// Or returns a new locator that matches elements from either the current
// locator or the given locator. The resulting locator resolves to all elements
// that match either selector, merged and sorted in DOM order.
func (l *Locator) Or(locator *Locator) *Locator {
return NewLocator(l.ctx, nil,
l.selector+" >> internal:or="+strconv.Quote(locator.selector),
l.frame, l.log)
}

// FrameLocator creates a frame locator for an iframe matching the given selector
// within the current locator's scope.
func (l *Locator) FrameLocator(selector string) *FrameLocator {
Expand Down
39 changes: 37 additions & 2 deletions internal/js/modules/k6/browser/common/selectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
package common

import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -56,8 +59,13 @@ func NewSelector(selector string) (*Selector, error) {
Parts: make([]*SelectorPart, 0, 1),
Capture: nil,
}
err := s.parse()
return &s, err
if err := s.parse(); err != nil {
return nil, err
}
if err := s.resolveNestedSelectors(); err != nil {
return nil, err
}
return &s, nil
}

func (s *Selector) appendPart(p *SelectorPart, capture bool) error {
Expand Down Expand Up @@ -163,3 +171,30 @@ func (s *Selector) parse() error {

return appendPart(start, index)
}

// inspired by playwright
// resolveNestedSelectors post-processes parsed parts that contain nested
// selectors (e.g. internal:or). The inner selector string is parsed into
// a Selector and then JSON-encoded back into the Body field so the
// injected script can recover it with JSON.parse.
func (s *Selector) resolveNestedSelectors() error {
for _, part := range s.Parts {
if part.Name != "internal:or" {
continue
}
inner, err := strconv.Unquote(part.Body)
if err != nil {
return fmt.Errorf("unquoting nested selector body %q: %w", part.Body, err)
}
parsed, err := NewSelector(inner)
if err != nil {
return fmt.Errorf("parsing nested selector %q: %w", inner, err)
}
encoded, err := json.Marshal(parsed)
if err != nil {
return fmt.Errorf("encoding nested selector: %w", err)
}
part.Body = string(encoded)
}
return nil
}
9 changes: 9 additions & 0 deletions internal/js/modules/k6/browser/common/selectors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ func TestSelectorParse(t *testing.T) {
},
expectedCap: nil,
},
{
name: "Internal or with nested selector",
input: `button >> internal:or="a"`,
expectedParts: []*SelectorPart{
{Name: "css", Body: "button"},
{Name: "internal:or", Body: `{"selector":"a","parts":[{"name":"css","body":"a"}],"capture":null}`},
},
expectedCap: nil,
},
}

for _, tc := range testCases {
Expand Down
55 changes: 55 additions & 0 deletions internal/js/modules/k6/browser/tests/locator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,61 @@ func TestLocator(t *testing.T) {
require.NoError(t, lo.WaitFor(opts))
},
},
{
"Or count", func(_ *testBrowser, p *common.Page) {
// 3 <a> elements + 1 <textarea> = 4 total
lo := p.Locator("a", nil).Or(p.Locator("textarea", nil))
n, err := lo.Count()
require.NoError(t, err)
assert.Equal(t, 4, n)
},
},
{
"Or isVisible", func(_ *testBrowser, p *common.Page) {
// #link exists, #nonExistent does not -- or yields exactly 1
lo := p.Locator("#nonExistent", nil).Or(p.Locator("#link", nil))
visible, err := lo.IsVisible()
require.NoError(t, err)
assert.True(t, visible)
},
},
{
"Or first element text", func(_ *testBrowser, p *common.Page) {
// The first <a> ("Click") comes before <textarea> in DOM order
lo := p.Locator("textarea", nil).Or(p.Locator("#link", nil))
text, err := lo.First().InnerText(common.NewFrameInnerTextOptions(lo.Timeout()))
require.NoError(t, err)
assert.Equal(t, "Click", text)
},
},
{
"Or no match on one side", func(_ *testBrowser, p *common.Page) {
// One locator matches nothing, the other matches 1 element
lo := p.Locator("#nonExistent", nil).Or(p.Locator("#link", nil))
n, err := lo.Count()
require.NoError(t, err)
assert.Equal(t, 1, n)
},
},
{
"Or chained three selectors", func(_ *testBrowser, p *common.Page) {
lo := p.Locator("#linkdbl", nil).
Or(p.Locator("#missing", nil)).
Or(p.Locator("#secondParagraph", nil))

locators, err := lo.All()
require.NoError(t, err)
require.Len(t, locators, 2)

text0, err := locators[0].InnerText(common.NewFrameInnerTextOptions(locators[0].Timeout()))
require.NoError(t, err)
assert.Equal(t, "Dblclick", text0)

text1, err := locators[1].InnerText(common.NewFrameInnerTextOptions(locators[1].Timeout()))
require.NoError(t, err)
assert.Equal(t, "original text", text1)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down