Skip to content
This repository was archived by the owner on Jan 30, 2025. It is now read-only.

Commit f54363b

Browse files
author
Ivan Mirić
authored
Fix waitForFunction() (#294)
* Fix waitForFunction() This is essentially a re-implementation of `waitForFunction()` since the previous version was broken in many ways. This required changes in `injected_script.js` where I hopefully didn't break anything else. I tried to split it into several commits but the changes were too messy and it would be very difficult at this point. In summary this: - Fixes `waitForFunction()` option parsing. - Ensures all three polling variants work: `requestAnimationFrame`, DOM mutation and interval. - Ensures we properly time out in all cases and return an informative error message. - Makes `waitForFunction()` an async method. Fixes #291 * Resolve promise on truthy value, return JSHandle Resolves #294 (comment) * Add more tests Resolves #294 (comment) * Update waitForFunction example * Add comments to waitForFunction eval calls
1 parent 510c993 commit f54363b

13 files changed

+395
-99
lines changed

api/frame.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ type Frame interface {
6767
Type(selector string, text string, opts goja.Value)
6868
Uncheck(selector string, opts goja.Value)
6969
URL() string
70-
WaitForFunction(pageFunc goja.Value, opts goja.Value, args ...goja.Value) JSHandle
70+
WaitForFunction(pageFunc, opts goja.Value, args ...goja.Value) *goja.Promise
7171
WaitForLoadState(state string, opts goja.Value)
7272
WaitForNavigation(opts goja.Value) Response
7373
WaitForSelector(selector string, opts goja.Value) ElementHandle

api/js_handle.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020

2121
package api
2222

23-
import "github.com/dop251/goja"
23+
import (
24+
cdpruntime "github.com/chromedp/cdproto/runtime"
25+
"github.com/dop251/goja"
26+
)
2427

2528
// JSHandle is the interface of an in-page JS object.
2629
type JSHandle interface {
@@ -31,4 +34,5 @@ type JSHandle interface {
3134
GetProperties() map[string]JSHandle
3235
GetProperty(propertyName string) JSHandle
3336
JSONValue() goja.Value
37+
ObjectID() cdpruntime.RemoteObjectID
3438
}

api/page.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ type Page interface {
8888
Video() Video
8989
ViewportSize() map[string]float64
9090
WaitForEvent(event string, optsOrPredicate goja.Value) interface{}
91-
WaitForFunction(pageFunc goja.Value, arg goja.Value, opts goja.Value) JSHandle
91+
WaitForFunction(fn, opts goja.Value, args ...goja.Value) *goja.Promise
9292
WaitForLoadState(state string, opts goja.Value)
9393
WaitForNavigation(opts goja.Value) Response
9494
WaitForRequest(urlOrPredicate, opts goja.Value) Request

common/element_handle.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ func (h *ElementHandle) waitForElementState(apiCtx context.Context, states []str
672672
}
673673
result, err := h.evalWithScript(apiCtx, opts, fn, states, timeout.Milliseconds())
674674
if err != nil {
675-
return false, err
675+
return false, errorFromDOMError(err.Error())
676676
}
677677

678678
value := result.(goja.Value)
@@ -1249,7 +1249,7 @@ func (h *ElementHandle) Uncheck(opts goja.Value) {
12491249
}
12501250

12511251
func (h *ElementHandle) WaitForElementState(state string, opts goja.Value) {
1252-
parsedOpts := NewElementHandleWaitForElementStateOptions(time.Duration(h.frame.manager.timeoutSettings.timeout()) * time.Second)
1252+
parsedOpts := NewElementHandleWaitForElementStateOptions(h.defaultTimeout())
12531253
err := parsedOpts.Parse(h.ctx, opts)
12541254
if err != nil {
12551255
k6Throw(h.ctx, "cannot parse element wait for state options: %w", err)
@@ -1458,7 +1458,7 @@ func retryPointerAction(
14581458

14591459
func errorFromDOMError(derr string) error {
14601460
// return the same sentinel error value for the timed out err
1461-
if derr == "error:timeout" {
1461+
if strings.Contains(derr, "timed out") {
14621462
return ErrTimedOut
14631463
}
14641464
if s := "error:expectednode:"; strings.HasPrefix(derr, s) {

common/element_handle_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestErrorFromDOMError(t *testing.T) {
1818
sentinel bool // if it returns the same error value
1919
want error
2020
}{
21-
{in: "error:timeout", want: ErrTimedOut, sentinel: true},
21+
{in: "timed out", want: ErrTimedOut, sentinel: true},
2222
{in: "error:notconnected", want: errors.New("element is not attached to the DOM")},
2323
{in: "error:expectednode:anything", want: errors.New("expected node but got anything")},
2424
{in: "nonexistent error", want: errors.New("nonexistent error")},

common/frame.go

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -567,13 +567,12 @@ func (f *Frame) waitForExecutionContext(world executionWorld) {
567567
}
568568

569569
func (f *Frame) waitForFunction(
570-
apiCtx context.Context, world executionWorld, predicateFn string,
571-
polling PollingType, interval int64, timeout time.Duration,
572-
args ...interface{},
573-
) (interface{}, error) {
570+
apiCtx context.Context, world executionWorld, js string,
571+
polling interface{}, timeout time.Duration, args ...interface{},
572+
) (*goja.Promise, error) {
574573
f.log.Debugf(
575574
"Frame:waitForFunction",
576-
"fid:%s furl:%q world:%s pt:%s timeout:%s",
575+
"fid:%s furl:%q world:%s poll:%s timeout:%s",
577576
f.ID(), f.URL(), world, polling, timeout)
578577

579578
f.waitForExecutionContext(world)
@@ -592,23 +591,53 @@ func (f *Frame) waitForFunction(
592591

593592
pageFn := `
594593
(injected, predicate, polling, timeout, ...args) => {
595-
return injected.waitForPredicateFunction(predicate, polling, timeout, ...args);
594+
const fn = (...args) => {
595+
return predicate(...args) || injected.continuePolling;
596+
}
597+
return injected.waitForPredicateFunction(fn, polling, timeout, ...args);
596598
}
597599
`
598-
opts := evalOptions{
599-
forceCallable: true,
600-
returnByValue: false,
601-
}
602-
result, err := execCtx.eval(
603-
apiCtx, opts, pageFn, append([]interface{}{
604-
injected,
605-
predicateFn,
606-
polling,
607-
}, args...)...)
608-
if err != nil {
609-
return nil, fmt.Errorf("frame cannot wait for function: %w", err)
610-
}
611-
return result, nil
600+
601+
cb := f.vu.RegisterCallback()
602+
rt := f.vu.Runtime()
603+
promise, resolve, reject := rt.NewPromise()
604+
605+
go func() {
606+
// First evaluate the predicate function itself to get its handle.
607+
opts := evalOptions{forceCallable: false, returnByValue: false}
608+
handle, err := execCtx.eval(apiCtx, opts, js)
609+
if err != nil {
610+
cb(func() error {
611+
reject(fmt.Errorf("waitForFunction promise rejected: %w", err))
612+
return nil
613+
})
614+
return
615+
}
616+
617+
// Then evaluate the injected function call, passing it the predicate
618+
// function handle and the rest of the arguments.
619+
opts = evalOptions{forceCallable: true, returnByValue: false}
620+
result, err := execCtx.eval(
621+
apiCtx, opts, pageFn, append([]interface{}{
622+
injected,
623+
handle,
624+
polling,
625+
timeout.Milliseconds(), // The JS value is in ms integers
626+
}, args...)...)
627+
if err != nil {
628+
cb(func() error {
629+
reject(fmt.Errorf("waitForFunction promise rejected: %w", err))
630+
return nil
631+
})
632+
return
633+
}
634+
cb(func() error {
635+
resolve(result)
636+
return nil
637+
})
638+
}()
639+
640+
return promise, nil
612641
}
613642

614643
func (f *Frame) waitForSelectorRetry(
@@ -1471,13 +1500,13 @@ func (f *Frame) setURL(url string) {
14711500
}
14721501

14731502
// WaitForFunction waits for the given predicate to return a truthy value.
1474-
func (f *Frame) WaitForFunction(fn goja.Value, opts goja.Value, jsArgs ...goja.Value) api.JSHandle {
1503+
func (f *Frame) WaitForFunction(fn goja.Value, opts goja.Value, jsArgs ...goja.Value) *goja.Promise {
14751504
f.log.Debugf("Frame:WaitForFunction", "fid:%s furl:%q", f.ID(), f.URL())
14761505

14771506
parsedOpts := NewFrameWaitForFunctionOptions(f.defaultTimeout())
14781507
err := parsedOpts.Parse(f.ctx, opts)
14791508
if err != nil {
1480-
k6Throw(f.ctx, "failed parsing options: %w", err)
1509+
k6Throw(f.ctx, "error parsing waitForFunction options: %w", err)
14811510
}
14821511

14831512
f.executionContextMu.RLock()
@@ -1491,15 +1520,23 @@ func (f *Frame) WaitForFunction(fn goja.Value, opts goja.Value, jsArgs ...goja.V
14911520

14921521
args := make([]interface{}, 0, len(jsArgs))
14931522
for _, a := range jsArgs {
1494-
args = append(args, a.Export())
1523+
if a != nil {
1524+
args = append(args, a.Export())
1525+
}
14951526
}
14961527

1497-
handle, err := f.waitForFunction(f.ctx, utilityWorld, js,
1498-
parsedOpts.Polling, parsedOpts.Interval, parsedOpts.Timeout, args...)
1528+
var polling interface{} = parsedOpts.Polling
1529+
if parsedOpts.Polling == PollingInterval {
1530+
polling = parsedOpts.Interval
1531+
}
1532+
1533+
promise, err := f.waitForFunction(f.ctx, mainWorld, js,
1534+
polling, parsedOpts.Timeout, args...)
14991535
if err != nil {
15001536
k6Throw(f.ctx, "%w", err)
15011537
}
1502-
return handle.(api.JSHandle)
1538+
1539+
return promise
15031540
}
15041541

15051542
// WaitForLoadState waits for the given load state to be reached.

common/frame_options.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,22 +605,31 @@ func NewFrameWaitForFunctionOptions(defaultTimeout time.Duration) *FrameWaitForF
605605
}
606606
}
607607

608+
// Parse JavaScript waitForFunction options.
608609
func (o *FrameWaitForFunctionOptions) Parse(ctx context.Context, opts goja.Value) error {
609610
rt := GetVU(ctx).Runtime()
610611

611612
if opts != nil && !goja.IsUndefined(opts) && !goja.IsNull(opts) {
612613
opts := opts.ToObject(rt)
613614
for _, k := range opts.Keys() {
615+
v := opts.Get(k)
614616
switch k {
615617
case "timeout":
616-
o.Timeout = time.Duration(opts.Get(k).ToInteger()) * time.Millisecond
618+
o.Timeout = time.Duration(v.ToInteger()) * time.Millisecond
617619
case "polling":
618-
switch opts.Get(k).ExportType().Kind() {
620+
switch v.ExportType().Kind() { //nolint: exhaustive
619621
case reflect.Int64:
620622
o.Polling = PollingInterval
621-
o.Interval = opts.Get(k).ToInteger()
623+
o.Interval = v.ToInteger()
624+
case reflect.String:
625+
if p, ok := pollingTypeToID[v.ToString().String()]; ok {
626+
o.Polling = p
627+
break
628+
}
629+
fallthrough
622630
default:
623-
o.Polling = PollingRaf
631+
return fmt.Errorf("wrong polling option value: %q; "+
632+
`possible values: "raf", "mutation" or number`, v)
624633
}
625634
}
626635
}

common/js/injected_script.js

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ const eventType = new Map([
4949
['mouseup', 'mouse'],
5050
['mouseleave', 'mouse'],
5151
['mousewheel', 'mouse'],
52-
52+
5353
['keydown', 'keyboard'],
5454
['keyup', 'keyboard'],
5555
['keypress', 'keyboard'],
5656
['textInput', 'keyboard'],
57-
57+
5858
['touchstart', 'touch'],
5959
['touchmove', 'touch'],
6060
['touchend', 'touch'],
6161
['touchcancel', 'touch'],
62-
62+
6363
['pointerover', 'pointer'],
6464
['pointerout', 'pointer'],
6565
['pointerenter', 'pointer'],
@@ -70,10 +70,10 @@ const eventType = new Map([
7070
['pointercancel', 'pointer'],
7171
['gotpointercapture', 'pointer'],
7272
['lostpointercapture', 'pointer'],
73-
73+
7474
['focus', 'focus'],
7575
['blur', 'focus'],
76-
76+
7777
['drag', 'drag'],
7878
['dragstart', 'drag'],
7979
['dragend', 'drag'],
@@ -138,6 +138,7 @@ class InjectedScript {
138138
constructor() {
139139
this._replaceRafWithTimeout = false;
140140
this._stableRafCount = 10;
141+
this.continuePolling = continuePolling;
141142
this._queryEngines = {
142143
'css': new CSSQueryEngine(),
143144
'text': new TextQueryEngine(),
@@ -587,30 +588,42 @@ class InjectedScript {
587588
}
588589

589590
async waitForPredicateFunction(predicate, polling, timeout, ...args) {
590-
predicate();
591591
let timedOut = false;
592-
if (timeout !== undefined || timeout !== null ) setTimeout(() => (timedOut = true), timeout);
592+
let timeoutPoll = null;
593+
if (timeout !== undefined || timeout !== null) {
594+
setTimeout(() => {
595+
timedOut = true;
596+
if (timeoutPoll) timeoutPoll();
597+
}, timeout);
598+
}
593599
if (polling === 'raf') return await pollRaf();
594600
if (polling === 'mutation') return await pollMutation();
595601
if (typeof polling === 'number') return await pollInterval(polling);
596602

597603
async function pollMutation() {
598-
const success = await predicate(...args);
599-
if (success) return Promise.resolve(success);
604+
const success = predicate(...args);
605+
if (success !== continuePolling) return Promise.resolve(success);
600606

601-
let fulfill;
602-
const result = new Promise((x) => (fulfill = x));
607+
let resolve, reject;
608+
const result = new Promise((res, rej) => {
609+
resolve = res;
610+
reject = rej;
611+
});
603612
const observer = new MutationObserver(async () => {
604613
if (timedOut) {
605614
observer.disconnect();
606-
fulfill("error:timeout");
615+
reject(`timed out after ${timeout}ms`);
607616
}
608617
const success = predicate(...args);
609618
if (success !== continuePolling) {
610619
observer.disconnect();
611-
fulfill(success);
620+
resolve(success);
612621
}
613622
});
623+
timeoutPoll = () => {
624+
observer.disconnect();
625+
reject(`timed out after ${timeout}ms`);
626+
};
614627
observer.observe(document, {
615628
childList: true,
616629
subtree: true,
@@ -620,35 +633,41 @@ class InjectedScript {
620633
}
621634

622635
async function pollRaf() {
623-
let fulfill;
624-
const result = new Promise((x) => (fulfill = x));
636+
let resolve, reject;
637+
const result = new Promise((res, rej) => {
638+
resolve = res;
639+
reject = rej;
640+
});
625641
await onRaf();
626642
return result;
627643

628644
async function onRaf() {
629645
if (timedOut) {
630-
fulfill("error:timeout");
646+
reject(`timed out after ${timeout}ms`);
631647
return;
632648
}
633649
const success = predicate(...args);
634-
if (success !== continuePolling) fulfill(success);
650+
if (success !== continuePolling) resolve(success);
635651
else requestAnimationFrame(onRaf);
636652
}
637653
}
638654

639655
async function pollInterval(pollInterval) {
640-
let fulfill;
641-
const result = new Promise((x) => (fulfill = x));
656+
let resolve, reject;
657+
const result = new Promise((res, rej) => {
658+
resolve = res;
659+
reject = rej;
660+
});
642661
await onTimeout();
643662
return result;
644663

645664
async function onTimeout() {
646665
if (timedOut) {
647-
fulfill("error:timeout");
666+
reject(`timed out after ${timeout}ms`);
648667
return;
649668
}
650669
const success = predicate(...args);
651-
if (success !== continuePolling) fulfill(success);
670+
if (success !== continuePolling) resolve(success);
652671
else setTimeout(onTimeout, pollInterval);
653672
}
654673
}
@@ -760,4 +779,4 @@ class InjectedScript {
760779

761780
return this.waitForPredicateFunction(predicate, polling, timeout, ...args);
762781
}
763-
}
782+
}

common/js_handle.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,8 @@ func (h *BaseJSHandle) JSONValue() goja.Value {
168168
}
169169
return res
170170
}
171+
172+
// ObjectID returns the remote object ID.
173+
func (h *BaseJSHandle) ObjectID() runtime.RemoteObjectID {
174+
return h.remoteObject.ObjectID
175+
}

0 commit comments

Comments
 (0)