Skip to content

Add waitForURL in frame and page #4920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 30, 2025
Merged
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
26 changes: 26 additions & 0 deletions internal/js/modules/k6/browser/browser/frame_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package browser

import (
"fmt"
"reflect"

"github.com/grafana/sobek"

Expand Down Expand Up @@ -422,6 +423,31 @@ func mapFrame(vu moduleVU, f *common.Frame) mapping {
return nil, nil
})
},
"waitForURL": func(url sobek.Value, opts sobek.Value) (*sobek.Promise, error) {
popts := common.NewFrameWaitForURLOptions(f.Timeout())
if err := popts.Parse(vu.Context(), opts); err != nil {
return nil, fmt.Errorf("parsing waitForURL options: %w", err)
}

var val string
switch url.ExportType() {
case reflect.TypeOf(string("")):
val = fmt.Sprintf("'%s'", url.String()) // Strings require quotes
default: // JS Regex, CSS, numbers or booleans
val = url.String() // No quotes
}
Comment on lines +432 to +438
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will refactor this into a helper method once this PR is merged in. There are several other PRs which work with similar/same logic to differentiate between regex and strings.


// Inject JS regex checker for URL pattern matching
ctx := vu.Context()
jsRegexChecker, err := injectRegexMatcherScript(ctx, vu, f.Page().TargetID())
if err != nil {
return nil, err
}

return k6ext.Promise(ctx, func() (result any, reason error) {
return nil, f.WaitForURL(val, popts, jsRegexChecker)
}), nil
},
}
maps["$"] = func(selector string) *sobek.Promise {
return k6ext.Promise(vu.Context(), func() (any, error) {
Expand Down
2 changes: 2 additions & 0 deletions internal/js/modules/k6/browser/browser/mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ type pageAPI interface { //nolint:interfacebloat
WaitForNavigation(opts sobek.Value) (*common.Response, error)
WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error)
WaitForTimeout(timeout int64)
WaitForURL(url string, opts sobek.Value) (*sobek.Promise, error)
Workers() []*common.Worker
}

Expand Down Expand Up @@ -434,6 +435,7 @@ type frameAPI interface { //nolint:interfacebloat
WaitForNavigation(opts sobek.Value) (*common.Response, error)
WaitForSelector(selector string, opts sobek.Value) (*common.ElementHandle, error)
WaitForTimeout(timeout int64)
WaitForURL(url string, opts sobek.Value) (*sobek.Promise, error)
}

// elementHandleAPI is the interface of an in-page DOM element.
Expand Down
25 changes: 25 additions & 0 deletions internal/js/modules/k6/browser/browser/page_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,31 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop
return nil, nil
})
},
"waitForURL": func(url sobek.Value, opts sobek.Value) (*sobek.Promise, error) {
popts := common.NewFrameWaitForURLOptions(p.Timeout())
if err := popts.Parse(vu.Context(), opts); err != nil {
return nil, fmt.Errorf("parsing waitForURL options: %w", err)
}

var val string
switch url.ExportType() {
case reflect.TypeOf(string("")):
val = fmt.Sprintf("'%s'", url.String()) // Strings require quotes
default: // JS Regex, CSS, numbers or booleans
val = url.String() // No quotes
}

// Inject JS regex checker for URL pattern matching
ctx := vu.Context()
jsRegexChecker, err := injectRegexMatcherScript(ctx, vu, p.TargetID())
if err != nil {
return nil, err
}

return k6ext.Promise(ctx, func() (result any, reason error) {
return nil, p.WaitForURL(val, popts, jsRegexChecker)
}), nil
},
"workers": func() *sobek.Object {
var mws []mapping
for _, w := range p.Workers() {
Expand Down
32 changes: 32 additions & 0 deletions internal/js/modules/k6/browser/common/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -2062,6 +2062,38 @@ func (f *Frame) WaitForTimeout(timeout int64) {
}
}

// WaitForURL waits for the frame to navigate to a URL matching the given pattern.
// jsRegexChecker should be non-nil to be able to test against a URL pattern.
func (f *Frame) WaitForURL(urlPattern string, opts *FrameWaitForURLOptions, jsRegexChecker JSRegexChecker) error {
f.log.Debugf("Frame:WaitForURL", "fid:%s furl:%q pattern:%s", f.ID(), f.URL(), urlPattern)
defer f.log.Debugf("Frame:WaitForURL:return", "fid:%s furl:%q pattern:%s", f.ID(), f.URL(), urlPattern)

matcher, err := urlMatcher(urlPattern, jsRegexChecker)
if err != nil {
return fmt.Errorf("parsing URL pattern: %w", err)
}

matched, err := matcher(f.URL())
if err != nil {
return fmt.Errorf("checking current URL: %w", err)
}
if matched {
// Already at target URL, just wait for load state
return f.WaitForLoadState(opts.WaitUntil.String(), &FrameWaitForLoadStateOptions{
Timeout: opts.Timeout,
})
}

// Wait for navigation to matching URL
navOpts := &FrameWaitForNavigationOptions{
URL: urlPattern,
Timeout: opts.Timeout,
WaitUntil: opts.WaitUntil,
}
_, err = f.WaitForNavigation(navOpts, jsRegexChecker)
return err
}

func (f *Frame) adoptBackendNodeID(world executionWorld, id cdp.BackendNodeID) (*ElementHandle, error) {
f.log.Debugf("Frame:adoptBackendNodeID", "fid:%s furl:%q world:%s id:%d", f.ID(), f.URL(), world, id)

Expand Down
34 changes: 34 additions & 0 deletions internal/js/modules/k6/browser/common/frame_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,3 +768,37 @@ func parseStrict(ctx context.Context, opts sobek.Value) bool {

return strict
}

// FrameWaitForURLOptions are options for Frame.waitForURL and Page.waitForURL.
type FrameWaitForURLOptions struct {
Timeout time.Duration
WaitUntil LifecycleEvent
}

// NewFrameWaitForURLOptions returns a new FrameWaitForURLOptions.
func NewFrameWaitForURLOptions(defaultTimeout time.Duration) *FrameWaitForURLOptions {
return &FrameWaitForURLOptions{
Timeout: defaultTimeout,
WaitUntil: LifecycleEventLoad,
}
}

// Parse parses the frame waitForURL options.
func (o *FrameWaitForURLOptions) Parse(ctx context.Context, opts sobek.Value) error {
rt := k6ext.Runtime(ctx)
if opts != nil && !sobek.IsUndefined(opts) && !sobek.IsNull(opts) {
opts := opts.ToObject(rt)
for _, k := range opts.Keys() {
switch k {
case "timeout":
o.Timeout = time.Duration(opts.Get(k).ToInteger()) * time.Millisecond
case "waitUntil":
lifeCycle := opts.Get(k).String()
if err := o.WaitUntil.UnmarshalText([]byte(lifeCycle)); err != nil {
return fmt.Errorf("parsing waitForURL options: %w", err)
}
}
}
}
return nil
}
16 changes: 16 additions & 0 deletions internal/js/modules/k6/browser/common/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,22 @@ func (p *Page) WaitForTimeout(timeout int64) {
p.frameManager.MainFrame().WaitForTimeout(timeout)
}

// WaitForURL waits for the page to navigate to a URL matching the given pattern.
// jsRegexChecker should be non-nil to be able to test against a URL pattern.
func (p *Page) WaitForURL(urlPattern string, opts *FrameWaitForURLOptions, jsRegexChecker JSRegexChecker) error {
p.logger.Debugf("Page:WaitForURL", "sid:%v pattern:%s", p.sessionID(), urlPattern)
_, span := TraceAPICall(p.ctx, p.targetID.String(), "page.waitForURL")
defer span.End()

err := p.frameManager.MainFrame().WaitForURL(urlPattern, opts, jsRegexChecker)
if err != nil {
spanRecordError(span, err)
return err
}

return nil
}

// Workers returns all WebWorkers of page.
func (p *Page) Workers() []*Worker {
p.workersMu.Lock()
Expand Down
106 changes: 106 additions & 0 deletions internal/js/modules/k6/browser/tests/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2891,3 +2891,109 @@ func TestWaitForNavigationWithURL_RegexFailure(t *testing.T) {
)
assert.ErrorContains(t, err, "Unexpected token *")
}

func TestWaitForURL(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: It might be clearer to split this into several tests (the %s are not easy to map with the actual value and if one test fails here, it won't let the others to continue, which helps with debugging and fixing all the failing tests faster).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, i agree. I will refactor the test in a refactoring PR along with this change i want to make.

if runtime.GOOS == "windows" {
t.Skip("Skipped due to https://github.com/grafana/k6/issues/4937")
}

t.Parallel()

tb := newTestBrowser(t, withFileServer())
tb.vu.ActivateVU()
tb.vu.StartIteration(t)

got := tb.vu.RunPromise(t, `
const page = await browser.newPage();
const testURL = '%s';

try {
// Test when already at matching URL (should just wait for load state)
await page.goto('%s');
await page.waitForURL(/.*page1\.html$/);
let currentURL = page.url();
if (!currentURL.endsWith('page1.html')) {
throw new Error('Expected to stay at page1.html but got ' + currentURL);
}

// Test exact URL match with navigation
await page.goto(testURL);
await Promise.all([
page.waitForURL('%s'),
page.locator('#page1').click()
]);
currentURL = page.url();
if (!currentURL.endsWith('page1.html')) {
throw new Error('Expected to navigate to page1.html but got ' + currentURL);
}

// Test regex pattern - matches any page with .html extension
await page.goto(testURL);
await Promise.all([
page.waitForURL(/.*\.html$/),
page.locator('#page2').click()
]);
currentURL = page.url();
if (!currentURL.endsWith('.html')) {
throw new Error('Expected URL to end with .html but got ' + currentURL);
}

// Test timeout when URL doesn't match
await page.goto(testURL);
let timedOut = false;
try {
await Promise.all([
page.waitForURL(/.*nonexistent\.html$/, { timeout: 500 }),
page.locator('#page1').click() // This goes to page1.html, not nonexistent.html
]);
} catch (error) {
if (error.toString().includes('waiting for navigation')) {
timedOut = true;
} else {
throw error;
}
}
if (!timedOut) {
throw new Error('Expected timeout error when URL does not match');
}

// Test empty pattern (matches any navigation)
await page.goto(testURL);
await Promise.all([
page.waitForURL(''),
page.locator('#page2').click()
]);
currentURL = page.url();
if (!currentURL.endsWith('page2.html') && !currentURL.endsWith('waitfornavigation_test.html')) {
throw new Error('Expected empty pattern to match any navigation but got ' + currentURL);
}

// Test waitUntil option
await page.goto(testURL);
await Promise.all([
page.waitForURL(/.*page1\.html$/, { waitUntil: 'domcontentloaded' }),
page.locator('#page1').click()
]);
currentURL = page.url();
if (!currentURL.endsWith('page1.html')) {
throw new Error('Expected to navigate to page1.html with domcontentloaded but got ' + currentURL);
}

// Test when already at URL with regex pattern
await page.goto(testURL);
await page.waitForURL(/.*\/waitfornavigation_test\.html$/);
currentURL = page.url();
if (!currentURL.endsWith('waitfornavigation_test.html')) {
throw new Error('Expected to stay at waitfornavigation_test.html but got ' + currentURL);
}
} finally {
// Must call close() which will clean up the taskqueue.
await page.close();
}
`,
tb.staticURL("waitfornavigation_test.html"),
tb.staticURL("page1.html"),
tb.staticURL("page1.html"),
)
assert.Equal(t, sobek.Undefined(), got.Result())
}
Loading