-
Notifications
You must be signed in to change notification settings - Fork 781
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
add exposefunc feature #1222
base: master
Are you sure you want to change the base?
add exposefunc feature #1222
Changes from 14 commits
343ba3f
6c36f85
a4b2a88
c539885
fbdd74d
5e7067d
fdab026
e336b8f
5ce5075
a6c065b
edfc02e
03c3fe6
9cbf548
12bc88b
fb276c4
c6081af
d1c146e
443b60e
5e27ea1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
package chromedp | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
|
||
"github.com/chromedp/cdproto/page" | ||
"github.com/chromedp/cdproto/runtime" | ||
) | ||
|
||
// ExposedFunc is the function type that can be exposed to the browser env. | ||
type ExposedFunc func(args string) (string, error) | ||
|
||
// ExposeAction are actions which expose Go functions to the browser env. | ||
type ExposeAction Action | ||
|
||
// Expose is an action to add a function called fnName on the browser page's | ||
// window object. When called, the function executes fn in the Go env and | ||
// returns a Promise which resolves to the return value of fn. | ||
// | ||
// Note: | ||
// 1. This is the lite version of puppeteer's [page.exposeFunction]. | ||
// 2. It adds "chromedpExposeFunc" to the page's window object too. | ||
// 3. The exposed function survives page navigation until the tab is closed? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think we have to answer this question. There is not way to revert this action. I think we should mention this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, no way to revert this action.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not only the binding, but also the scripts that will be evaluated on new document ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It only takes effect on the page after navigation. The target(Page) hasn't changed. this kind of behavior should be understood There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Most state does not survive page navigation. But the scripts that the expose action uses survive page navigation and there is no way to stop that (unless the tab is closed), so this behavior should be documented. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get it. |
||
// 4. (iframe?) | ||
ZekeLu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 5. Avoid exposing multiple funcs with the same name. | ||
// 6. Maybe you just need runtime.AddBinding. | ||
// | ||
// [page.exposeFunction]: https://github.com/puppeteer/puppeteer/blob/v19.2.2/docs/api/puppeteer.page.exposefunction.md | ||
func Expose(fnName string, fn ExposedFunc) ExposeAction { | ||
return ActionFunc(func(ctx context.Context) error { | ||
|
||
expression := fmt.Sprintf(`chromedpExposeFunc.wrapBinding("exposedFun","%s");`, fnName) | ||
|
||
err := Run(ctx, | ||
runtime.AddBinding(fnName), | ||
evaluateOnAllFrames(exposeJS), | ||
evaluateOnAllFrames(expression), | ||
// Make it effective after navigation. | ||
addScriptToEvaluateOnNewDocument(exposeJS), | ||
addScriptToEvaluateOnNewDocument(expression), | ||
) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
ListenTarget(ctx, func(ev interface{}) { | ||
switch ev := ev.(type) { | ||
case *runtime.EventBindingCalled: | ||
if ev.Payload == "" { | ||
return | ||
} | ||
|
||
var payload struct { | ||
Type string `json:"type"` | ||
Name string `json:"name"` | ||
Seq int64 `json:"seq"` | ||
Args string `json:"args"` | ||
} | ||
|
||
err := json.Unmarshal([]byte(ev.Payload), &payload) | ||
if err != nil { | ||
return | ||
} | ||
|
||
if payload.Type != "exposedFun" || payload.Name != fnName { | ||
return | ||
} | ||
|
||
result, err := fn(payload.Args) | ||
|
||
callback := "chromedpExposeFunc.deliverResult" | ||
if err != nil { | ||
result = err.Error() | ||
callback = "chromedpExposeFunc.deliverError" | ||
} | ||
|
||
// Prevent the message from being processed by other functions | ||
ev.Payload = "" | ||
|
||
go func() { | ||
err := Run(ctx, | ||
CallFunctionOn(callback, | ||
nil, | ||
func(p *runtime.CallFunctionOnParams) *runtime.CallFunctionOnParams { | ||
return p.WithExecutionContextID(ev.ExecutionContextID) | ||
}, | ||
payload.Name, | ||
payload.Seq, | ||
result, | ||
), | ||
) | ||
|
||
if err != nil { | ||
c := FromContext(ctx) | ||
c.Browser.errf("failed to deliver result to exposed func %s: %s", fnName, err) | ||
} | ||
}() | ||
} | ||
}) | ||
|
||
return nil | ||
}) | ||
} | ||
|
||
func addScriptToEvaluateOnNewDocument(script string) Action { | ||
return ActionFunc(func(ctx context.Context) error { | ||
_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx) | ||
return err | ||
}) | ||
} | ||
|
||
func evaluateOnAllFrames(script string) Action { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ZekeLu expose the function to all frames. please check this function. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need a test case to cover it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test cases have been added and a bug has been fixed |
||
return ActionFunc(func(ctx context.Context) error { | ||
c := FromContext(ctx) | ||
|
||
c.Target.frameMu.RLock() | ||
actions := make([]Action, 0, len(c.Target.execContexts)) | ||
for _, executionContextID := range c.Target.execContexts { | ||
actions = append(actions, Evaluate(script, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithContextID(executionContextID) | ||
})) | ||
} | ||
c.Target.frameMu.RUnlock() | ||
|
||
return Tasks(actions).Do(ctx) | ||
}) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package chromedp | ||
|
||
import ( | ||
"crypto/md5" | ||
"encoding/base64" | ||
"encoding/hex" | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/chromedp/cdproto/runtime" | ||
) | ||
|
||
func md5SumFunc(args string) (string, error) { | ||
h := md5.New() | ||
h.Write([]byte(args)) | ||
return hex.EncodeToString(h.Sum(nil)), nil | ||
} | ||
|
||
func base64EncodeFunc(args string) (string, error) { | ||
return base64.StdEncoding.EncodeToString([]byte(testString)), nil | ||
} | ||
|
||
const testString = "chromedp expose test" | ||
const testStringMd5 = "a93d69002a286b46c8aa114362afb7ac" | ||
const testStringBase64 = "Y2hyb21lZHAgZXhwb3NlIHRlc3Q=" | ||
|
||
func TestExpose(t *testing.T) { | ||
// allocate browser | ||
ctx, cancel := testAllocate(t, "") | ||
defer cancel() | ||
|
||
// expose md5SumFunc function as md5 to browser current page and every frame | ||
if err := Run(ctx, Expose("md5", md5SumFunc)); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// expose base64EncodeFunc function as base64 to browser current page and every frame | ||
if err := Run(ctx, Expose("base64", base64EncodeFunc)); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// 1. When on the current page | ||
var res string | ||
callMd5 := fmt.Sprintf(`%s("%s");`, "md5", testString) | ||
if err := Run(ctx, Evaluate(callMd5, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithAwaitPromise(true) | ||
})); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
if res != testStringMd5 { | ||
t.Fatalf("want: %s, got: %s", testStringMd5, res) | ||
} | ||
|
||
var res2 string | ||
callBase64 := fmt.Sprintf(`%s("%s");`, "base64", testString) | ||
if err := Run(ctx, Evaluate(callBase64, &res2, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithAwaitPromise(true) | ||
})); err != nil { | ||
t.Fatal(err) | ||
} | ||
if res2 != testStringBase64 { | ||
t.Fatalf("want: %s, got: %s", testStringBase64, res) | ||
} | ||
|
||
// 2. Navigate another page | ||
if err := Run(ctx, | ||
Navigate(testdataDir+"/child1.html"), | ||
); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// we expect md5 can work properly. | ||
if err := Run(ctx, Evaluate(callMd5, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithAwaitPromise(true) | ||
})); err != nil { | ||
t.Fatal(err) | ||
} | ||
if res != testStringMd5 { | ||
t.Fatalf("want: %s, got: %s", testStringMd5, res) | ||
} | ||
|
||
// we expect base64 can work properly. | ||
if err := Run(ctx, Evaluate(callBase64, &res2, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithAwaitPromise(true) | ||
})); err != nil { | ||
t.Fatal(err) | ||
} | ||
if res2 != testStringBase64 { | ||
t.Fatalf("want: %s, got: %s", testStringBase64, res) | ||
} | ||
} | ||
|
||
func TestExposeMulti(t *testing.T) { | ||
// allocate browser | ||
ctx, cancel := testAllocate(t, "") | ||
defer cancel() | ||
|
||
// creates a new page. about:blank | ||
Run(ctx) | ||
|
||
// expose md5SumFunc function as sameFunc to browser current page and every frame | ||
if err := Run(ctx, Expose("sameFunc", md5SumFunc)); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// expose base64EncodeFunc function as sameFunc to browser current page and every frame | ||
if err := Run(ctx, Expose("sameFunc", base64EncodeFunc)); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
// we expect first expose function to handle | ||
var res string | ||
sameFunc := fmt.Sprintf(`%s("%s");`, "sameFunc", testString) | ||
if err := Run(ctx, Evaluate(sameFunc, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { | ||
return p.WithAwaitPromise(true) | ||
})); err != nil { | ||
t.Fatal(err) | ||
} | ||
|
||
if res != testStringMd5 { | ||
t.Fatalf("want md5SumFunc res:%s, got:%s", testStringMd5, res) | ||
} | ||
if res == testStringBase64 { | ||
t.Fatalf("want md5SumFunc res:%s, got base64EncodeFunc res :%s", testStringMd5, res) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
var chromedpExposeFunc = chromedpExposeFunc || { | ||
bindings: {}, | ||
deliverError: function (name, seq, message) { | ||
const error = new Error(message); | ||
chromedpExposeFunc.bindings[name].callbacks.get(seq).reject(error); | ||
chromedpExposeFunc.bindings[name].callbacks.delete(seq); | ||
}, | ||
deliverResult: function (name, seq, result) { | ||
chromedpExposeFunc.bindings[name].callbacks.get(seq).resolve(result); | ||
ZekeLu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
chromedpExposeFunc.bindings[name].callbacks.delete(seq); | ||
}, | ||
wrapBinding: function (type, name) { | ||
// Store the binding function added by the call of runtime.AddBinding. | ||
chromedpExposeFunc.bindings[name] = window[name]; | ||
|
||
// Replace the binding function. | ||
Object.assign(window, { | ||
[name](args) { | ||
if (typeof args != 'string') { | ||
return Promise.reject( | ||
new Error( | ||
'function takes exactly one argument, this argument should be string' | ||
) | ||
); | ||
} | ||
|
||
const binding = chromedpExposeFunc.bindings[name]; | ||
|
||
binding.callbacks ??= new Map(); | ||
|
||
const seq = (binding.lastSeq ?? 0) + 1; | ||
binding.lastSeq = seq; | ||
|
||
// Call the binding function to trigger runtime.EventBindingCalled. | ||
binding(JSON.stringify({ type, name, seq, args })); | ||
|
||
return new Promise((resolve, reject) => { | ||
binding.callbacks.set(seq, { resolve, reject }); | ||
}); | ||
}, | ||
}); | ||
}, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be more flexible if the args parameter was defined as an interface{} type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason for this design is that it is consistent with the cdp bindingCalled event parameter, which can only pass string.
Refer to
Runtime.bindingCalled
https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#event-bindingCalled