Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
17 changes: 17 additions & 0 deletions internal/js/modules/k6/browser/browser/frame_locator_mapping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package browser

import (
"github.com/grafana/sobek"
"go.k6.io/k6/internal/js/modules/k6/browser/common"
)

// mapFrameLocator API to the JS module.
func mapFrameLocator(vu moduleVU, fl *common.FrameLocator) mapping {
rt := vu.Runtime()
return mapping{
"locator": func(selector string) *sobek.Object {
ml := mapLocator(vu, fl.Locator(selector))
return rt.ToValue(ml).ToObject(rt)
},
}
}
4 changes: 4 additions & 0 deletions internal/js/modules/k6/browser/browser/locator_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func mapLocator(vu moduleVU, lo *common.Locator) mapping {
return nil, lo.Click(popts) //nolint:wrapcheck
}), nil
},
"contentFrame": func() *sobek.Object {
ml := mapFrameLocator(vu, lo.ContentFrame())
return rt.ToValue(ml).ToObject(rt)
},
"count": func() *sobek.Promise {
return k6ext.Promise(vu.Context(), func() (any, error) {
return lo.Count() //nolint:wrapcheck
Expand Down
11 changes: 11 additions & 0 deletions internal/js/modules/k6/browser/browser/mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ func TestMappings(t *testing.T) {
return mapElementHandle(moduleVU{VU: vu}, &common.ElementHandle{})
},
},
"frameLocator": {
apiInterface: (*frameLocatorAPI)(nil),
mapp: func() mapping {
return mapFrameLocator(moduleVU{VU: vu}, &common.FrameLocator{})
},
},
"jsHandle": {
apiInterface: (*common.JSHandleAPI)(nil),
mapp: func() mapping {
Expand Down Expand Up @@ -493,6 +499,10 @@ type elementHandleAPI interface { //nolint:interfacebloat
WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error)
}

type frameLocatorAPI interface {
Locator(selector string) *common.Locator
}

// requestAPI is the interface of an HTTP request.
type requestAPI interface { //nolint:interfacebloat
AllHeaders() map[string]string
Expand Down Expand Up @@ -538,6 +548,7 @@ type locatorAPI interface { //nolint:interfacebloat
BoundingBox(opts *common.FrameBaseOptions) (*common.Rect, error)
Clear(opts *common.FrameFillOptions) error
Click(opts sobek.Value) error
ContentFrame() *common.FrameLocator
Count() (int, error)
Dblclick(opts sobek.Value) error
SetChecked(checked bool, opts sobek.Value) error
Expand Down
143 changes: 143 additions & 0 deletions internal/js/modules/k6/browser/common/element_handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,13 +708,68 @@ func (h *ElementHandle) waitForElementState(
"waiting for states %v of element %q", states, reflect.TypeOf(result))
}

// stepIntoFrame steps into an iframe/frame. Due to CORS, we need to perform this
// step outside of the browser (chromium). It returns the frame that it has stepped
// into and the selector to use within that frame.
func (h *ElementHandle) stepIntoFrame(
apiCtx context.Context, parsedSelector *Selector, frameNavIndex int, opts *FrameWaitForSelectorOptions,
) (*Frame, string, error) {
// Split selector at frame navigation boundary
beforeFrame, afterFrame := h.splitSelectorAtFrame(parsedSelector, frameNavIndex)

// Find the iframe element using the "before frame" selector
iframeSelector := h.reconstructSelector(beforeFrame)

iframeHandle, err := h.waitForSelector(apiCtx, iframeSelector, opts)
if err != nil {
return nil, "", fmt.Errorf("finding iframe with selector %q: %w", iframeSelector, err)
}

// This is a valid response from waitForSelector. It means that the element
// was either hidden or detached.
if iframeHandle == nil {
return nil, "", errors.New("check if element is visible")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Use new visible error from #5111

}

frame, err := iframeHandle.ContentFrame()
if err != nil {
return nil, "", fmt.Errorf("getting iframe frame: %w", err)
}

// Wait for selector in the iframe using the "after frame" selector
afterFrameSelector := h.reconstructSelector(afterFrame)

return frame, afterFrameSelector, nil
}

func (h *ElementHandle) waitForSelector(
apiCtx context.Context, selector string, opts *FrameWaitForSelectorOptions,
) (*ElementHandle, error) {
parsedSelector, err := NewSelector(selector)
if err != nil {
return nil, err
}

// Check for frame navigation in the selector
frameNavIndex := h.findFrameNavigationIndex(parsedSelector)
if frameNavIndex != -1 {
// Strict is true because we assume the user is interested in the element
// in the frame and not the frame it is in.
opts := &FrameWaitForSelectorOptions{
State: DOMElementStateAttached,
Timeout: opts.Timeout,
Strict: true,
}

frame, afterFrameSelector, err := h.stepIntoFrame(apiCtx, parsedSelector, frameNavIndex, opts)
if err != nil {
return nil, err
}
Comment on lines +754 to +767
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about extracting this to another function as it's used a lot throughout the file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I was hoping to do a better job at abstracting this. I managed to abstract most of the core logic into stepIntoFrame, but some of it still remains (call to findFrameNavigationIndex, creation of FrameWaitForSelectorOptions and a recursive call to the method it is in). I feel like what is left isn't easily generalised, there are ever so slight differences.

I won't completely disregard this, and think it through again. Let me know if you spot anything obvious though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had an idea but when trying to implement it, it's too complicated and doesn't help with readability.


return frame.waitForSelector(afterFrameSelector, opts)
}

// No frame navigation - proceed with normal waitForSelector logic
fn := `
(node, injected, selector, strict, state, timeout, ...args) => {
return injected.waitForSelector(selector, node, strict, state, 'raf', timeout, ...args);
Expand All @@ -736,6 +791,8 @@ func (h *ElementHandle) waitForSelector(
case *ElementHandle:
return r, nil
default:
// This is a valid response, which means that the element was either
// hidden or detached.
return nil, nil //nolint:nilnil
}
}
Expand All @@ -746,6 +803,26 @@ func (h *ElementHandle) count(apiCtx context.Context, selector string) (int, err
return 0, err
}

// Check for frame navigation in the selector
frameNavIndex := h.findFrameNavigationIndex(parsedSelector)
if frameNavIndex != -1 {
// Strict is true because we assume the user is interested in the element
// in the frame and not the frame it is in.
opts := &FrameWaitForSelectorOptions{
State: DOMElementStateAttached,
Timeout: h.frame.defaultTimeout(),
Strict: true,
}

frame, afterFrameSelector, err := h.stepIntoFrame(apiCtx, parsedSelector, frameNavIndex, opts)
if err != nil {
return 0, err
}

return frame.count(afterFrameSelector)
}

// No frame navigation - proceed with normal count logic
fn := `
(node, injected, selector) => {
return injected.count(selector, node);
Expand Down Expand Up @@ -773,6 +850,51 @@ func (h *ElementHandle) count(apiCtx context.Context, selector string) (int, err
}
}

// findFrameNavigationIndex finds the index of the first internal:control=enter-frame directive
func (h *ElementHandle) findFrameNavigationIndex(selector *Selector) int {
for i, part := range selector.Parts {
if part.Name == "internal:control" && part.Body == "enter-frame" {
return i
}
}
return -1
}

// splitSelectorAtFrame splits a selector at the frame navigation boundary
func (h *ElementHandle) splitSelectorAtFrame(selector *Selector, frameIndex int) (*Selector, *Selector) {
beforeFrame := &Selector{
Selector: selector.Selector, // Keep original for reference
Parts: selector.Parts[:frameIndex],
Capture: selector.Capture,
}

afterFrame := &Selector{
Selector: selector.Selector, // Keep original for reference
Parts: selector.Parts[frameIndex+1:],
Capture: selector.Capture,
}

return beforeFrame, afterFrame
}

// reconstructSelector rebuilds a selector string from selector parts
func (h *ElementHandle) reconstructSelector(selector *Selector) string {
if len(selector.Parts) == 0 {
return ""
}

parts := make([]string, len(selector.Parts))
for i, part := range selector.Parts {
if part.Name == "css" {
parts[i] = part.Body
} else {
parts[i] = part.Name + "=" + part.Body
}
}

return strings.Join(parts, " >> ")
}

// AsElement returns this element handle.
func (h *ElementHandle) AsElement() *ElementHandle {
return h
Expand Down Expand Up @@ -1151,6 +1273,27 @@ func (h *ElementHandle) Query(selector string, strict bool) (_ *ElementHandle, r
if err != nil {
return nil, fmt.Errorf("parsing selector %q: %w", selector, err)
}

// Check for frame navigation in the selector
frameNavIndex := h.findFrameNavigationIndex(parsedSelector)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be added to the QueryAll function as well?

Copy link
Contributor Author

@ankur22 ankur22 Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did originally consider it but decided against it. The reason for not wanting to add to queryAll is that there's currently no way to chain such a directive to a selector for it to work in queryAll. This chaining and stepping into iframes from a different origin is really only there to support locator and frameLocator based APIs. queryAll isn't a locator based API, and I think it shouldn't be needed now that we have locator.all 🤔 Looking at the Playwright docs (and a quick google/AI search) it looks like there's no way to step into an iframe with queryAll.

I added it to query because locator.isVisible and locator.isHidden use it -- i do wonder if they should be refactored to not work with query 🤔

if frameNavIndex != -1 {
// Strict is true because we assume the user is interested in the element
// in the frame and not the frame it is in.
opts := &FrameWaitForSelectorOptions{
State: DOMElementStateAttached,
Timeout: h.frame.defaultTimeout(),
Strict: true,
}

frame, afterFrameSelector, err := h.stepIntoFrame(h.ctx, parsedSelector, frameNavIndex, opts)
if err != nil {
return nil, err
}

return frame.Query(afterFrameSelector, strict)
}

// No frame navigation - proceed with normal Query logic
querySelector := `
(node, injected, selector, strict) => {
return injected.querySelector(selector, strict, node || document);
Expand Down
3 changes: 3 additions & 0 deletions internal/js/modules/k6/browser/common/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,9 @@ func (f *Frame) waitFor(
if strings.Contains(err.Error(), "Execution context was destroyed") {
return f.waitFor(selector, opts, retryCount)
}
if strings.Contains(err.Error(), "visible") {
return f.waitFor(selector, opts, retryCount)
}
}

return handle, err
Expand Down
34 changes: 34 additions & 0 deletions internal/js/modules/k6/browser/common/frame_locator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package common

import (
"context"

"go.k6.io/k6/internal/js/modules/k6/browser/log"
)

// FrameLocator represent a way to find element(s) in an iframe.
type FrameLocator struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's OK, but I'd expect to find this in the same locator.go file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that? It's a new type, which does work like a locator, but it's use case is specific to when working with iframes.

Copy link
Contributor

@inancgumus inancgumus Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I could've explained it better 😄 I like the idiomatic Go approach of collecting multiple types and functionalities in the same file for related functionality (e.g., http's client and server aspects). Although FrameLocator is a new type, it's one aspect of a Locator.

selector string

frame *Frame

ctx context.Context
log *log.Logger
}

// NewFrameLocator creates and returns a new frame locator.
func NewFrameLocator(ctx context.Context, selector string, f *Frame, l *log.Logger) *FrameLocator {
return &FrameLocator{
selector: selector,
frame: f,
ctx: ctx,
log: l,
}
}

// Locator creates and returns a new locator chained/relative to the current FrameLocator.
func (fl *FrameLocator) Locator(selector string) *Locator {
// Add frame navigation marker to indicate we need to enter the frame's contentDocument
frameNavSelector := fl.selector + " >> internal:control=enter-frame >> " + selector
return NewLocator(fl.ctx, nil, frameNavSelector, fl.frame, fl.log)
}
7 changes: 7 additions & 0 deletions internal/js/modules/k6/browser/common/locator.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ func (l *Locator) All() ([]*Locator, error) {
return locators, nil
}

// ContentFrame creates and returns a new FrameLocator, which is useful when
// needing to interact with elements in an iframe and the current locator already
// points to the iframe.
func (l *Locator) ContentFrame() *FrameLocator {
return NewFrameLocator(l.ctx, l.selector, l.frame, l.log)
}

// Count APIs do not wait for the element to be present. It also does not set
// strict to true, allowing it to return the total number of elements matching
// the selector.
Expand Down
3 changes: 3 additions & 0 deletions internal/js/modules/k6/browser/common/selectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func (s *Selector) parse() error {
case strings.HasPrefix(part, "internal:text="):
name = "internal:text"
body = part
case strings.HasPrefix(part, "internal:control="):
name = "internal:control"
body = part[eqIndex+1:]
default:
name = "css"
body = part
Expand Down
Loading
Loading