diff --git a/internal/js/modules/k6/browser/browser/frame_mapping.go b/internal/js/modules/k6/browser/browser/frame_mapping.go index 8f04cfd4e67..73e70ceae0a 100644 --- a/internal/js/modules/k6/browser/browser/frame_mapping.go +++ b/internal/js/modules/k6/browser/browser/frame_mapping.go @@ -2,6 +2,7 @@ package browser import ( "fmt" + "reflect" "github.com/grafana/sobek" @@ -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 + } + + // 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) { diff --git a/internal/js/modules/k6/browser/browser/mapping_test.go b/internal/js/modules/k6/browser/browser/mapping_test.go index 4a0776f9952..23ff0a0cd59 100644 --- a/internal/js/modules/k6/browser/browser/mapping_test.go +++ b/internal/js/modules/k6/browser/browser/mapping_test.go @@ -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 } @@ -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. diff --git a/internal/js/modules/k6/browser/browser/page_mapping.go b/internal/js/modules/k6/browser/browser/page_mapping.go index 4b69370be18..e33d67c9904 100644 --- a/internal/js/modules/k6/browser/browser/page_mapping.go +++ b/internal/js/modules/k6/browser/browser/page_mapping.go @@ -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() { diff --git a/internal/js/modules/k6/browser/common/frame.go b/internal/js/modules/k6/browser/common/frame.go index ec2a3079e1b..e17fc3435a9 100644 --- a/internal/js/modules/k6/browser/common/frame.go +++ b/internal/js/modules/k6/browser/common/frame.go @@ -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) diff --git a/internal/js/modules/k6/browser/common/frame_options.go b/internal/js/modules/k6/browser/common/frame_options.go index 9854b8bf529..f12ac1dd822 100644 --- a/internal/js/modules/k6/browser/common/frame_options.go +++ b/internal/js/modules/k6/browser/common/frame_options.go @@ -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 +} diff --git a/internal/js/modules/k6/browser/common/page.go b/internal/js/modules/k6/browser/common/page.go index f3506fb5023..cedb980dbc8 100644 --- a/internal/js/modules/k6/browser/common/page.go +++ b/internal/js/modules/k6/browser/common/page.go @@ -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() diff --git a/internal/js/modules/k6/browser/tests/page_test.go b/internal/js/modules/k6/browser/tests/page_test.go index c87e8b9dd49..90fcf3a8fde 100644 --- a/internal/js/modules/k6/browser/tests/page_test.go +++ b/internal/js/modules/k6/browser/tests/page_test.go @@ -2891,3 +2891,109 @@ func TestWaitForNavigationWithURL_RegexFailure(t *testing.T) { ) assert.ErrorContains(t, err, "Unexpected token *") } + +func TestWaitForURL(t *testing.T) { + 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()) +}