From 343ba3f1daed646ed0126fec8b85507e4a4dbac4 Mon Sep 17 00:00:00 2001 From: hillguo Date: Mon, 12 Dec 2022 15:50:47 +0800 Subject: [PATCH 01/12] add exposefunc feature --- errors.go | 3 + expose.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ target.go | 4 ++ 3 files changed, 172 insertions(+) create mode 100644 expose.go diff --git a/errors.go b/errors.go index 94d763b4..f7e55a57 100644 --- a/errors.go +++ b/errors.go @@ -48,4 +48,7 @@ const ( // ErrPollingTimeout is the error that the timeout reached before the pageFunction returns a truthy value. ErrPollingTimeout Error = "waiting for function failed: timeout" + + // ErrExposeNameExist target expose with name already exists! + ErrExposeNameExist Error = "target expose with name already exists!" ) diff --git a/expose.go b/expose.go new file mode 100644 index 00000000..5632b66c --- /dev/null +++ b/expose.go @@ -0,0 +1,165 @@ +package chromedp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/runtime" +) + +// BindingCalledPayload ... +type BindingCalledPayload struct { + Type string `json:"type"` + Name string `json:"name"` + Seq int64 `json:"seq"` + Args string `json:"args"` +} + +// BindingFunc expose function type +type BindingFunc func(args string) (string, error) + +// ExposeFunc The method adds a function called name on the browser page's window object. +// When called, the function executes BindingFunc in go env +// and returns a Promise which resolves to the return value of BindingFunc. +// Note. compared with puppeteer's exposeFunction. +// the BindingFunc takes exactly one argument, this argument should be string +func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { + c := FromContext(ctx) + if c == nil { + return ErrInvalidContext + } + if c.Target == nil { + if err := c.newTarget(ctx); err != nil { + return err + } + } + + c.Target.bindingFuncListenOnce.Do(func() { + c.Target.bindingFuncs = make(map[string]BindingFunc) + + err := Run(ctx, ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(exposedFunJS).Do(ctx) + return err + })) + if err != nil { + return + } + + ListenTarget(ctx, func(ev interface{}) { + switch ev := ev.(type) { + case *runtime.EventBindingCalled: + var payload BindingCalledPayload + + err := json.Unmarshal([]byte(ev.Payload), &payload) + if err != nil { + return + } + + var expression string + + c.Target.bindingFuncMu.RLock() + defer c.Target.bindingFuncMu.RUnlock() + if fn, ok := c.Target.bindingFuncs[payload.Name]; ok { + res, err := fn(payload.Args) + if err != nil { + expression = deliverError(payload.Name, payload.Seq, err.Error(), err.Error()) + } else { + expression = deliverResult(payload.Name, payload.Seq, res) + } + } else { + expression = deliverError(payload.Name, payload.Seq, "bindingCall name not exsit", "") + } + + go func() { + var res []byte + err := Run(ctx, Evaluate(expression, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithContextID(ev.ExecutionContextID) + })) + + if err != nil { + fmt.Println(err) + } + }() + + } + }) + }) + + c.Target.bindingFuncMu.Lock() + if _, ok := c.Target.bindingFuncs[fnName]; ok { + c.Target.bindingFuncMu.Unlock() + return ErrExposeNameExist + } + c.Target.bindingFuncs[fnName] = fn + c.Target.bindingFuncMu.Unlock() + + err := Run(ctx, runtime.AddBinding(fnName)) + if err != nil { + return err + } + + expression := addPageBinding("exposedFun", fnName) + err = Run(ctx, ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(expression).Do(ctx) + return err + })) + if err != nil { + return err + } + return nil +} + +const exposedFunJS = ` +function deliverError(name, seq, message, stack) { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); +} + +function deliverResult(name, seq, result) { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); +} + +function addPageBinding(type, name) { + // This is the CDP binding. + const callCDP = self[name]; + console.log("callCDP",callCDP) + // We replace the CDP binding with a Puppeteer binding. + Object.assign(self, { + [name](args) { + if(typeof args != "string"){ + return Promise.reject(new Error('function takes exactly one argument, this argument should be string')) + } + var _a, _b; + // This is the Puppeteer binding. + const callPuppeteer = self[name]; + (_a = callPuppeteer.callbacks) !== null && _a !== void 0 ? _a : (callPuppeteer.callbacks = new Map()); + const seq = ((_b = callPuppeteer.lastSeq) !== null && _b !== void 0 ? _b : 0) + 1; + callPuppeteer.lastSeq = seq; + callCDP(JSON.stringify({ type, name, seq, args })); + return new Promise((resolve, reject) => { + callPuppeteer.callbacks.set(seq, { resolve, reject }); + }); + }, + }); +} +` + +func deliverError(name string, seq int64, message, stack string) string { + var cmd string = `deliverError("%s",%d,"%s","%s");` + return fmt.Sprintf(cmd, name, seq, message, stack) +} + +func deliverResult(name string, seq int64, result string) string { + var cmd string = `deliverResult("%s",%d,"%s");` + return fmt.Sprintf(cmd, name, seq, result) +} + +func addPageBinding(typeS, name string) string { + var cmd string = `addPageBinding("%s","%s");` + return fmt.Sprintf(cmd, typeS, name) +} diff --git a/target.go b/target.go index 2adfd6c0..1c3d2ebe 100644 --- a/target.go +++ b/target.go @@ -41,6 +41,10 @@ type Target struct { // Indicates if the target is a worker target. isWorker bool + + bindingFuncs map[string]BindingFunc + bindingFuncMu sync.RWMutex + bindingFuncListenOnce sync.Once } func (t *Target) enclosingFrame(node *cdp.Node) cdp.FrameID { From 6c36f855ded6417ac5db37062d000c1724e526c6 Mon Sep 17 00:00:00 2001 From: hillguo Date: Mon, 12 Dec 2022 16:40:34 +0800 Subject: [PATCH 02/12] add exposefunc feature --- expose.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/expose.go b/expose.go index 5632b66c..dcc6323c 100644 --- a/expose.go +++ b/expose.go @@ -73,14 +73,9 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { } go func() { - var res []byte - err := Run(ctx, Evaluate(expression, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + Run(ctx, Evaluate(expression, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithContextID(ev.ExecutionContextID) })) - - if err != nil { - fmt.Println(err) - } }() } From a4b2a8834a23d58fc8a55b4d085a41923e8719da Mon Sep 17 00:00:00 2001 From: hillguo Date: Tue, 13 Dec 2022 18:21:28 +0800 Subject: [PATCH 03/12] --test=optimize the code and add test cases --- expose.go | 85 +++++++++++--------------------------------- expose_test.go | 71 ++++++++++++++++++++++++++++++++++++ js.go | 7 ++++ js/expose.js | 35 ++++++++++++++++++ testdata/expose.html | 9 +++++ 5 files changed, 143 insertions(+), 64 deletions(-) create mode 100644 expose_test.go create mode 100644 js/expose.js create mode 100644 testdata/expose.html diff --git a/expose.go b/expose.go index dcc6323c..a5cd7088 100644 --- a/expose.go +++ b/expose.go @@ -9,6 +9,12 @@ import ( "github.com/chromedp/cdproto/runtime" ) +const ( + deliverError = "deliverError" + deliverResult = "deliverResult" + addTargetBinding = "addTargetBinding" +) + // BindingCalledPayload ... type BindingCalledPayload struct { Type string `json:"type"` @@ -40,7 +46,7 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { c.Target.bindingFuncs = make(map[string]BindingFunc) err := Run(ctx, ActionFunc(func(ctx context.Context) error { - _, err := page.AddScriptToEvaluateOnNewDocument(exposedFunJS).Do(ctx) + _, err := page.AddScriptToEvaluateOnNewDocument(exposeJS).Do(ctx) return err })) if err != nil { @@ -57,25 +63,29 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { return } - var expression string + if payload.Type != "exposedFun" { + return + } c.Target.bindingFuncMu.RLock() defer c.Target.bindingFuncMu.RUnlock() + + result := "bindingCall name not exsit" + callFnName := deliverError + if fn, ok := c.Target.bindingFuncs[payload.Name]; ok { - res, err := fn(payload.Args) + result, err = fn(payload.Args) if err != nil { - expression = deliverError(payload.Name, payload.Seq, err.Error(), err.Error()) + result = err.Error() } else { - expression = deliverResult(payload.Name, payload.Seq, res) + callFnName = deliverResult } - } else { - expression = deliverError(payload.Name, payload.Seq, "bindingCall name not exsit", "") } go func() { - Run(ctx, Evaluate(expression, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { - return p.WithContextID(ev.ExecutionContextID) - })) + Run(ctx, CallFunctionOn(callFnName, nil, func(p *runtime.CallFunctionOnParams) *runtime.CallFunctionOnParams { + return p.WithExecutionContextID(ev.ExecutionContextID) + }, payload.Name, payload.Seq, result)) }() } @@ -95,7 +105,7 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { return err } - expression := addPageBinding("exposedFun", fnName) + expression := fmt.Sprintf(`%s("%s","%s");`, addTargetBinding, "exposedFun", fnName) err = Run(ctx, ActionFunc(func(ctx context.Context) error { _, err := page.AddScriptToEvaluateOnNewDocument(expression).Do(ctx) return err @@ -105,56 +115,3 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { } return nil } - -const exposedFunJS = ` -function deliverError(name, seq, message, stack) { - const error = new Error(message); - error.stack = stack; - window[name].callbacks.get(seq).reject(error); - window[name].callbacks.delete(seq); -} - -function deliverResult(name, seq, result) { - window[name].callbacks.get(seq).resolve(result); - window[name].callbacks.delete(seq); -} - -function addPageBinding(type, name) { - // This is the CDP binding. - const callCDP = self[name]; - console.log("callCDP",callCDP) - // We replace the CDP binding with a Puppeteer binding. - Object.assign(self, { - [name](args) { - if(typeof args != "string"){ - return Promise.reject(new Error('function takes exactly one argument, this argument should be string')) - } - var _a, _b; - // This is the Puppeteer binding. - const callPuppeteer = self[name]; - (_a = callPuppeteer.callbacks) !== null && _a !== void 0 ? _a : (callPuppeteer.callbacks = new Map()); - const seq = ((_b = callPuppeteer.lastSeq) !== null && _b !== void 0 ? _b : 0) + 1; - callPuppeteer.lastSeq = seq; - callCDP(JSON.stringify({ type, name, seq, args })); - return new Promise((resolve, reject) => { - callPuppeteer.callbacks.set(seq, { resolve, reject }); - }); - }, - }); -} -` - -func deliverError(name string, seq int64, message, stack string) string { - var cmd string = `deliverError("%s",%d,"%s","%s");` - return fmt.Sprintf(cmd, name, seq, message, stack) -} - -func deliverResult(name string, seq int64, result string) string { - var cmd string = `deliverResult("%s",%d,"%s");` - return fmt.Sprintf(cmd, name, seq, result) -} - -func addPageBinding(typeS, name string) string { - var cmd string = `addPageBinding("%s","%s");` - return fmt.Sprintf(cmd, typeS, name) -} diff --git a/expose_test.go b/expose_test.go new file mode 100644 index 00000000..cfcfb1d0 --- /dev/null +++ b/expose_test.go @@ -0,0 +1,71 @@ +package chromedp + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/chromedp/cdproto/runtime" +) + +func TestExposeFunc(t *testing.T) { + + tests := []struct { + name string + Func BindingFunc + param string + result string + }{ + { + name: "func1", + Func: func(param string) (string, error) { + return param + param, nil + }, + param: "param1", + result: "param1param1", + }, + { + name: "func2", + Func: func(param string) (string, error) { + return strings.ToUpper(param), nil + }, + param: "param2", + result: "PARAM2", + }, + } + + ctx, cancel := testAllocate(t, "") + defer cancel() + + for _, test := range tests { + err := ExposeFunc(ctx, test.name, test.Func) + if err != nil { + t.Fatal(err) + } + } + + if err := Run(ctx, + Navigate(testdataDir+"/expose.html"), + ); err != nil { + t.Fatal(err) + } + + for _, test := range tests { + var res []byte + + cmd := fmt.Sprintf(`%s("%s");`, test.name, test.param) + err := Run(ctx, Evaluate(cmd, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithAwaitPromise(true) + })) + + if err != nil { + t.Fatal(err) + } + // When res is a *[]byte, the raw JSON-encoded value of the script result will be placed in res. + data, _ := json.Marshal(test.result) + if string(data) != string(res) { + t.Fatalf("want result: %s, got : %s", string(data), string(res)) + } + } +} diff --git a/js.go b/js.go index 191e8351..1f149428 100644 --- a/js.go +++ b/js.go @@ -56,4 +56,11 @@ var ( // It's modified to make mutation polling respect timeout even when there is not DOM mutation. //go:embed js/waitForPredicatePageFunction.js waitForPredicatePageFunction string + + // exposedJS is a javascript snippet that wraps the function (CDP binding) + // It's copied from puppeteer. See + // https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/common/util.ts#L248-L327 + // It's modified to make BindingFunc takes exactly one argument, this argument should be string + //go:embed js/expose.js + exposeJS string ) diff --git a/js/expose.js b/js/expose.js new file mode 100644 index 00000000..c6492d50 --- /dev/null +++ b/js/expose.js @@ -0,0 +1,35 @@ +function deliverError(name, seq, message, stack) { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); +} + +function deliverResult(name, seq, result) { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); +} + +function addTargetBinding(type, name) { + // This is the CDP binding. + const callCDP = window[name]; + + // We replace the CDP binding with a chromedp binding. + Object.assign(window, { + [name](args) { + if(typeof args != "string"){ + return Promise.reject(new Error('function takes exactly one argument, this argument should be string')) + } + var _a, _b; + // This is the chromedp binding. + const callChromedp = window[name]; + (_a = callChromedp.callbacks) !== null && _a !== void 0 ? _a : (callChromedp.callbacks = new Map()); + const seq = ((_b = callChromedp.lastSeq) !== null && _b !== void 0 ? _b : 0) + 1; + callChromedp.lastSeq = seq; + callCDP(JSON.stringify({ type, name, seq, args })); + return new Promise((resolve, reject) => { + callChromedp.callbacks.set(seq, { resolve, reject }); + }); + }, + }); +} \ No newline at end of file diff --git a/testdata/expose.html b/testdata/expose.html new file mode 100644 index 00000000..804aa5fe --- /dev/null +++ b/testdata/expose.html @@ -0,0 +1,9 @@ + + + + chromedp expose + + + + + From c5398859a8a951933d5b400f3787cc130eb36d7e Mon Sep 17 00:00:00 2001 From: hillguo Date: Mon, 12 Dec 2022 15:50:47 +0800 Subject: [PATCH 04/12] --test=optimize the code and add test cases --- errors.go | 3 ++ expose.go | 117 +++++++++++++++++++++++++++++++++++++++++++ expose_test.go | 71 ++++++++++++++++++++++++++ js.go | 7 +++ js/expose.js | 35 +++++++++++++ target.go | 4 ++ testdata/expose.html | 9 ++++ 7 files changed, 246 insertions(+) create mode 100644 expose.go create mode 100644 expose_test.go create mode 100644 js/expose.js create mode 100644 testdata/expose.html diff --git a/errors.go b/errors.go index 94d763b4..f7e55a57 100644 --- a/errors.go +++ b/errors.go @@ -48,4 +48,7 @@ const ( // ErrPollingTimeout is the error that the timeout reached before the pageFunction returns a truthy value. ErrPollingTimeout Error = "waiting for function failed: timeout" + + // ErrExposeNameExist target expose with name already exists! + ErrExposeNameExist Error = "target expose with name already exists!" ) diff --git a/expose.go b/expose.go new file mode 100644 index 00000000..a5cd7088 --- /dev/null +++ b/expose.go @@ -0,0 +1,117 @@ +package chromedp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/runtime" +) + +const ( + deliverError = "deliverError" + deliverResult = "deliverResult" + addTargetBinding = "addTargetBinding" +) + +// BindingCalledPayload ... +type BindingCalledPayload struct { + Type string `json:"type"` + Name string `json:"name"` + Seq int64 `json:"seq"` + Args string `json:"args"` +} + +// BindingFunc expose function type +type BindingFunc func(args string) (string, error) + +// ExposeFunc The method adds a function called name on the browser page's window object. +// When called, the function executes BindingFunc in go env +// and returns a Promise which resolves to the return value of BindingFunc. +// Note. compared with puppeteer's exposeFunction. +// the BindingFunc takes exactly one argument, this argument should be string +func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { + c := FromContext(ctx) + if c == nil { + return ErrInvalidContext + } + if c.Target == nil { + if err := c.newTarget(ctx); err != nil { + return err + } + } + + c.Target.bindingFuncListenOnce.Do(func() { + c.Target.bindingFuncs = make(map[string]BindingFunc) + + err := Run(ctx, ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(exposeJS).Do(ctx) + return err + })) + if err != nil { + return + } + + ListenTarget(ctx, func(ev interface{}) { + switch ev := ev.(type) { + case *runtime.EventBindingCalled: + var payload BindingCalledPayload + + err := json.Unmarshal([]byte(ev.Payload), &payload) + if err != nil { + return + } + + if payload.Type != "exposedFun" { + return + } + + c.Target.bindingFuncMu.RLock() + defer c.Target.bindingFuncMu.RUnlock() + + result := "bindingCall name not exsit" + callFnName := deliverError + + if fn, ok := c.Target.bindingFuncs[payload.Name]; ok { + result, err = fn(payload.Args) + if err != nil { + result = err.Error() + } else { + callFnName = deliverResult + } + } + + go func() { + Run(ctx, CallFunctionOn(callFnName, nil, func(p *runtime.CallFunctionOnParams) *runtime.CallFunctionOnParams { + return p.WithExecutionContextID(ev.ExecutionContextID) + }, payload.Name, payload.Seq, result)) + }() + + } + }) + }) + + c.Target.bindingFuncMu.Lock() + if _, ok := c.Target.bindingFuncs[fnName]; ok { + c.Target.bindingFuncMu.Unlock() + return ErrExposeNameExist + } + c.Target.bindingFuncs[fnName] = fn + c.Target.bindingFuncMu.Unlock() + + err := Run(ctx, runtime.AddBinding(fnName)) + if err != nil { + return err + } + + expression := fmt.Sprintf(`%s("%s","%s");`, addTargetBinding, "exposedFun", fnName) + err = Run(ctx, ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(expression).Do(ctx) + return err + })) + if err != nil { + return err + } + return nil +} diff --git a/expose_test.go b/expose_test.go new file mode 100644 index 00000000..cfcfb1d0 --- /dev/null +++ b/expose_test.go @@ -0,0 +1,71 @@ +package chromedp + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/chromedp/cdproto/runtime" +) + +func TestExposeFunc(t *testing.T) { + + tests := []struct { + name string + Func BindingFunc + param string + result string + }{ + { + name: "func1", + Func: func(param string) (string, error) { + return param + param, nil + }, + param: "param1", + result: "param1param1", + }, + { + name: "func2", + Func: func(param string) (string, error) { + return strings.ToUpper(param), nil + }, + param: "param2", + result: "PARAM2", + }, + } + + ctx, cancel := testAllocate(t, "") + defer cancel() + + for _, test := range tests { + err := ExposeFunc(ctx, test.name, test.Func) + if err != nil { + t.Fatal(err) + } + } + + if err := Run(ctx, + Navigate(testdataDir+"/expose.html"), + ); err != nil { + t.Fatal(err) + } + + for _, test := range tests { + var res []byte + + cmd := fmt.Sprintf(`%s("%s");`, test.name, test.param) + err := Run(ctx, Evaluate(cmd, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithAwaitPromise(true) + })) + + if err != nil { + t.Fatal(err) + } + // When res is a *[]byte, the raw JSON-encoded value of the script result will be placed in res. + data, _ := json.Marshal(test.result) + if string(data) != string(res) { + t.Fatalf("want result: %s, got : %s", string(data), string(res)) + } + } +} diff --git a/js.go b/js.go index 191e8351..1f149428 100644 --- a/js.go +++ b/js.go @@ -56,4 +56,11 @@ var ( // It's modified to make mutation polling respect timeout even when there is not DOM mutation. //go:embed js/waitForPredicatePageFunction.js waitForPredicatePageFunction string + + // exposedJS is a javascript snippet that wraps the function (CDP binding) + // It's copied from puppeteer. See + // https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/common/util.ts#L248-L327 + // It's modified to make BindingFunc takes exactly one argument, this argument should be string + //go:embed js/expose.js + exposeJS string ) diff --git a/js/expose.js b/js/expose.js new file mode 100644 index 00000000..c6492d50 --- /dev/null +++ b/js/expose.js @@ -0,0 +1,35 @@ +function deliverError(name, seq, message, stack) { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); +} + +function deliverResult(name, seq, result) { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); +} + +function addTargetBinding(type, name) { + // This is the CDP binding. + const callCDP = window[name]; + + // We replace the CDP binding with a chromedp binding. + Object.assign(window, { + [name](args) { + if(typeof args != "string"){ + return Promise.reject(new Error('function takes exactly one argument, this argument should be string')) + } + var _a, _b; + // This is the chromedp binding. + const callChromedp = window[name]; + (_a = callChromedp.callbacks) !== null && _a !== void 0 ? _a : (callChromedp.callbacks = new Map()); + const seq = ((_b = callChromedp.lastSeq) !== null && _b !== void 0 ? _b : 0) + 1; + callChromedp.lastSeq = seq; + callCDP(JSON.stringify({ type, name, seq, args })); + return new Promise((resolve, reject) => { + callChromedp.callbacks.set(seq, { resolve, reject }); + }); + }, + }); +} \ No newline at end of file diff --git a/target.go b/target.go index 2adfd6c0..1c3d2ebe 100644 --- a/target.go +++ b/target.go @@ -41,6 +41,10 @@ type Target struct { // Indicates if the target is a worker target. isWorker bool + + bindingFuncs map[string]BindingFunc + bindingFuncMu sync.RWMutex + bindingFuncListenOnce sync.Once } func (t *Target) enclosingFrame(node *cdp.Node) cdp.FrameID { diff --git a/testdata/expose.html b/testdata/expose.html new file mode 100644 index 00000000..804aa5fe --- /dev/null +++ b/testdata/expose.html @@ -0,0 +1,9 @@ + + + + chromedp expose + + + + + From fbdd74d278f7e2a63033efe3ea035994fafa87a9 Mon Sep 17 00:00:00 2001 From: hillguo Date: Thu, 29 Dec 2022 18:08:08 +0800 Subject: [PATCH 05/12] modify test examples --- expose_test.go | 65 ++++++++++++++++---------------------------------- 1 file changed, 20 insertions(+), 45 deletions(-) diff --git a/expose_test.go b/expose_test.go index cfcfb1d0..76319f7b 100644 --- a/expose_test.go +++ b/expose_test.go @@ -1,9 +1,9 @@ package chromedp import ( - "encoding/json" + "crypto/md5" + "encoding/hex" "fmt" - "strings" "testing" "github.com/chromedp/cdproto/runtime" @@ -11,38 +11,15 @@ import ( func TestExposeFunc(t *testing.T) { - tests := []struct { - name string - Func BindingFunc - param string - result string - }{ - { - name: "func1", - Func: func(param string) (string, error) { - return param + param, nil - }, - param: "param1", - result: "param1param1", - }, - { - name: "func2", - Func: func(param string) (string, error) { - return strings.ToUpper(param), nil - }, - param: "param2", - result: "PARAM2", - }, - } - ctx, cancel := testAllocate(t, "") defer cancel() - for _, test := range tests { - err := ExposeFunc(ctx, test.name, test.Func) - if err != nil { - t.Fatal(err) - } + if err := ExposeFunc(ctx, "md5", func(args string) (string, error) { + h := md5.New() + h.Write([]byte(args)) + return hex.EncodeToString(h.Sum(nil)), nil + }); err != nil { + t.Fatal(err) } if err := Run(ctx, @@ -51,21 +28,19 @@ func TestExposeFunc(t *testing.T) { t.Fatal(err) } - for _, test := range tests { - var res []byte + var res string + cmd := fmt.Sprintf(`%s("%s");`, "md5", "chromedp") + if err := Run(ctx, Evaluate(cmd, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithAwaitPromise(true) + })); err != nil { + t.Fatal(err) + } - cmd := fmt.Sprintf(`%s("%s");`, test.name, test.param) - err := Run(ctx, Evaluate(cmd, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { - return p.WithAwaitPromise(true) - })) + h := md5.New() + h.Write([]byte("chromedp")) + md5Str := hex.EncodeToString(h.Sum(nil)) - if err != nil { - t.Fatal(err) - } - // When res is a *[]byte, the raw JSON-encoded value of the script result will be placed in res. - data, _ := json.Marshal(test.result) - if string(data) != string(res) { - t.Fatalf("want result: %s, got : %s", string(data), string(res)) - } + if res != md5Str { + t.Fatalf("want: %s, got: %s", md5Str, res) } } From fdab0262f0ebc0764caa7ea94511acca6a637e2e Mon Sep 17 00:00:00 2001 From: hillguo Date: Wed, 1 Feb 2023 20:42:34 +0800 Subject: [PATCH 06/12] change expose feature as chromedp's action --- expose.go | 108 +++++++++++++++++++++++------------------------ expose_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++------- js/expose.js | 34 +++++++++------ 3 files changed, 174 insertions(+), 80 deletions(-) diff --git a/expose.go b/expose.go index a5cd7088..0f52f38e 100644 --- a/expose.go +++ b/expose.go @@ -26,31 +26,55 @@ type BindingCalledPayload struct { // BindingFunc expose function type type BindingFunc func(args string) (string, error) -// ExposeFunc The method adds a function called name on the browser page's window object. +// AddScriptToEvaluateOnNewDocument ... +func AddScriptToEvaluateOnNewDocument(script string) Action { + return ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx) + return err + }) +} + +// ExposeAction are actions which expose local functions to 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 BindingFunc in go env // and returns a Promise which resolves to the return value of BindingFunc. // Note. compared with puppeteer's exposeFunction. // the BindingFunc takes exactly one argument, this argument should be string -func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { - c := FromContext(ctx) - if c == nil { - return ErrInvalidContext - } - if c.Target == nil { - if err := c.newTarget(ctx); err != nil { +// Note. Do not expose the same function name many times, it will only take effect for the first time. +func Expose(fnName string, fn BindingFunc) ExposeAction { + return ActionFunc(func(ctx context.Context) error { + + // adds binding with the given name on the global objects of all inspected contexts + err := Run(ctx, runtime.AddBinding(fnName)) + if err != nil { return err } - } - c.Target.bindingFuncListenOnce.Do(func() { - c.Target.bindingFuncs = make(map[string]BindingFunc) + expression := fmt.Sprintf(`%s("%s","%s");`, addTargetBinding, "cdpExposedFun", fnName) - err := Run(ctx, ActionFunc(func(ctx context.Context) error { - _, err := page.AddScriptToEvaluateOnNewDocument(exposeJS).Do(ctx) + // inject bindingFunc wrapper into current window + err = Run(ctx, Evaluate(exposeJS, nil)) + if err != nil { return err - })) + } + + err = Run(ctx, Evaluate(expression, nil)) if err != nil { - return + return err + } + + // we also want to make it effective after nav url + // it evaluates given script in every frame upon creation (before loading frame's scripts) + err = Run(ctx, AddScriptToEvaluateOnNewDocument(exposeJS)) + if err != nil { + return err + } + + err = Run(ctx, AddScriptToEvaluateOnNewDocument(expression)) + if err != nil { + return err } ListenTarget(ctx, func(ev interface{}) { @@ -63,55 +87,31 @@ func ExposeFunc(ctx context.Context, fnName string, fn BindingFunc) error { return } - if payload.Type != "exposedFun" { + if payload.Type != "cdpExposedFun" { return } - c.Target.bindingFuncMu.RLock() - defer c.Target.bindingFuncMu.RUnlock() + if payload.Name == fnName { + callFnName := deliverResult + result, err := fn(payload.Args) - result := "bindingCall name not exsit" - callFnName := deliverError - - if fn, ok := c.Target.bindingFuncs[payload.Name]; ok { - result, err = fn(payload.Args) if err != nil { result = err.Error() - } else { - callFnName = deliverResult + callFnName = deliverError } - } - go func() { - Run(ctx, CallFunctionOn(callFnName, nil, func(p *runtime.CallFunctionOnParams) *runtime.CallFunctionOnParams { - return p.WithExecutionContextID(ev.ExecutionContextID) - }, payload.Name, payload.Seq, result)) - }() + // Prevent the message from being processed by other functions + ev.Payload = "" + go func() { + Run(ctx, CallFunctionOn(callFnName, nil, func(p *runtime.CallFunctionOnParams) *runtime.CallFunctionOnParams { + return p.WithExecutionContextID(ev.ExecutionContextID) + }, payload.Name, payload.Seq, result)) + }() + } } }) - }) - - c.Target.bindingFuncMu.Lock() - if _, ok := c.Target.bindingFuncs[fnName]; ok { - c.Target.bindingFuncMu.Unlock() - return ErrExposeNameExist - } - c.Target.bindingFuncs[fnName] = fn - c.Target.bindingFuncMu.Unlock() - - err := Run(ctx, runtime.AddBinding(fnName)) - if err != nil { - return err - } - expression := fmt.Sprintf(`%s("%s","%s");`, addTargetBinding, "exposedFun", fnName) - err = Run(ctx, ActionFunc(func(ctx context.Context) error { - _, err := page.AddScriptToEvaluateOnNewDocument(expression).Do(ctx) - return err - })) - if err != nil { - return err - } - return nil + return nil + }) } diff --git a/expose_test.go b/expose_test.go index 76319f7b..04331ac2 100644 --- a/expose_test.go +++ b/expose_test.go @@ -2,6 +2,7 @@ package chromedp import ( "crypto/md5" + "encoding/base64" "encoding/hex" "fmt" "testing" @@ -9,38 +10,121 @@ import ( "github.com/chromedp/cdproto/runtime" ) -func TestExposeFunc(t *testing.T) { +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() - if err := ExposeFunc(ctx, "md5", func(args string) (string, error) { - h := md5.New() - h.Write([]byte(args)) - return hex.EncodeToString(h.Sum(nil)), nil - }); err != nil { + // creates a new page. about:blank + Run(ctx) + + // 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+"/expose.html"), ); err != nil { t.Fatal(err) } - var res string - cmd := fmt.Sprintf(`%s("%s");`, "md5", "chromedp") - if err := Run(ctx, Evaluate(cmd, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + // 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) + } - h := md5.New() - h.Write([]byte("chromedp")) - md5Str := hex.EncodeToString(h.Sum(nil)) + // 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 != md5Str { - t.Fatalf("want: %s, got: %s", md5Str, res) + 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) } } diff --git a/js/expose.js b/js/expose.js index c6492d50..b2a6d150 100644 --- a/js/expose.js +++ b/js/expose.js @@ -1,35 +1,45 @@ function deliverError(name, seq, message, stack) { const error = new Error(message); error.stack = stack; - window[name].callbacks.get(seq).reject(error); - window[name].callbacks.delete(seq); + window["CDP_BINDING_" + name].callbacks.get(seq).reject(error); + window["CDP_BINDING_" + name].callbacks.delete(seq); } function deliverResult(name, seq, result) { - window[name].callbacks.get(seq).resolve(result); - window[name].callbacks.delete(seq); + window["CDP_BINDING_" + name].callbacks.get(seq).resolve(result); + window["CDP_BINDING_" + name].callbacks.delete(seq); } function addTargetBinding(type, name) { // This is the CDP binding. - const callCDP = window[name]; - + window["CDP_BINDING_" + name] = window[name]; + // We replace the CDP binding with a chromedp binding. Object.assign(window, { [name](args) { if(typeof args != "string"){ return Promise.reject(new Error('function takes exactly one argument, this argument should be string')) } - var _a, _b; + // This is the chromedp binding. - const callChromedp = window[name]; - (_a = callChromedp.callbacks) !== null && _a !== void 0 ? _a : (callChromedp.callbacks = new Map()); - const seq = ((_b = callChromedp.lastSeq) !== null && _b !== void 0 ? _b : 0) + 1; + const callChromedp = window["CDP_BINDING_" + name]; + + if (callChromedp.callbacks == undefined) { + callChromedp.callbacks = new Map() + } + if (callChromedp.lastSeq == undefined) { + callChromedp.lastSeq = 0 + } + + const seq = callChromedp.lastSeq + 1 callChromedp.lastSeq = seq; - callCDP(JSON.stringify({ type, name, seq, args })); + + callChromedp(JSON.stringify({ type, name, seq, args })); + return new Promise((resolve, reject) => { callChromedp.callbacks.set(seq, { resolve, reject }); }); }, }); -} \ No newline at end of file +} + From e336b8f7bccd94d9755f65db2b884f355322e197 Mon Sep 17 00:00:00 2001 From: hillguo Date: Wed, 1 Feb 2023 20:49:43 +0800 Subject: [PATCH 07/12] change expose feature as chromedp's action --- errors.go | 3 --- js.go | 2 +- target.go | 4 ---- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/errors.go b/errors.go index f7e55a57..94d763b4 100644 --- a/errors.go +++ b/errors.go @@ -48,7 +48,4 @@ const ( // ErrPollingTimeout is the error that the timeout reached before the pageFunction returns a truthy value. ErrPollingTimeout Error = "waiting for function failed: timeout" - - // ErrExposeNameExist target expose with name already exists! - ErrExposeNameExist Error = "target expose with name already exists!" ) diff --git a/js.go b/js.go index 1f149428..1d61fb8e 100644 --- a/js.go +++ b/js.go @@ -58,7 +58,7 @@ var ( waitForPredicatePageFunction string // exposedJS is a javascript snippet that wraps the function (CDP binding) - // It's copied from puppeteer. See + // It refers to puppeteer. See // https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/common/util.ts#L248-L327 // It's modified to make BindingFunc takes exactly one argument, this argument should be string //go:embed js/expose.js diff --git a/target.go b/target.go index 1c3d2ebe..2adfd6c0 100644 --- a/target.go +++ b/target.go @@ -41,10 +41,6 @@ type Target struct { // Indicates if the target is a worker target. isWorker bool - - bindingFuncs map[string]BindingFunc - bindingFuncMu sync.RWMutex - bindingFuncListenOnce sync.Once } func (t *Target) enclosingFrame(node *cdp.Node) cdp.FrameID { From 5ce5075d14c29de13894a0056e8b419e093dceca Mon Sep 17 00:00:00 2001 From: hillguo Date: Wed, 1 Feb 2023 21:04:37 +0800 Subject: [PATCH 08/12] Add trailing new line --- js/expose.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/expose.js b/js/expose.js index b2a6d150..d3135816 100644 --- a/js/expose.js +++ b/js/expose.js @@ -42,4 +42,3 @@ function addTargetBinding(type, name) { }, }); } - From edfc02e00f9d4dd24505ec20efbab1631fbf16d8 Mon Sep 17 00:00:00 2001 From: Zeke Lu Date: Tue, 14 Feb 2023 19:23:02 +0800 Subject: [PATCH 09/12] review --- expose.go | 146 +++++++++++++++++++++---------------------- expose_test.go | 5 +- js/expose.js | 75 +++++++++++----------- testdata/expose.html | 9 --- 4 files changed, 108 insertions(+), 127 deletions(-) delete mode 100644 testdata/expose.html diff --git a/expose.go b/expose.go index 0f52f38e..e4ba8eda 100644 --- a/expose.go +++ b/expose.go @@ -9,70 +9,37 @@ import ( "github.com/chromedp/cdproto/runtime" ) -const ( - deliverError = "deliverError" - deliverResult = "deliverResult" - addTargetBinding = "addTargetBinding" -) - -// BindingCalledPayload ... -type BindingCalledPayload struct { - Type string `json:"type"` - Name string `json:"name"` - Seq int64 `json:"seq"` - Args string `json:"args"` -} +// ExposedFunc is the function type that can be exposed to the browser env. +type ExposedFunc func(args string) (string, error) -// BindingFunc expose function type -type BindingFunc func(args string) (string, error) - -// AddScriptToEvaluateOnNewDocument ... -func AddScriptToEvaluateOnNewDocument(script string) Action { - return ActionFunc(func(ctx context.Context) error { - _, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx) - return err - }) -} - -// ExposeAction are actions which expose local functions to browser env. +// 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 BindingFunc in go env -// and returns a Promise which resolves to the return value of BindingFunc. -// Note. compared with puppeteer's exposeFunction. -// the BindingFunc takes exactly one argument, this argument should be string -// Note. Do not expose the same function name many times, it will only take effect for the first time. -func Expose(fnName string, fn BindingFunc) ExposeAction { +// 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? +// 4. (iframe?) +// 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 { - // adds binding with the given name on the global objects of all inspected contexts - err := Run(ctx, runtime.AddBinding(fnName)) - if err != nil { - return err - } - - expression := fmt.Sprintf(`%s("%s","%s");`, addTargetBinding, "cdpExposedFun", fnName) - - // inject bindingFunc wrapper into current window - err = Run(ctx, Evaluate(exposeJS, nil)) - if err != nil { - return err - } - - err = Run(ctx, Evaluate(expression, nil)) - if err != nil { - return err - } - - // we also want to make it effective after nav url - // it evaluates given script in every frame upon creation (before loading frame's scripts) - err = Run(ctx, AddScriptToEvaluateOnNewDocument(exposeJS)) - if err != nil { - return err - } - - err = Run(ctx, AddScriptToEvaluateOnNewDocument(expression)) + expression := fmt.Sprintf(`chromedpExposeFunc.wrapBinding("exposedFun","%s");`, fnName) + err := Run(ctx, + runtime.AddBinding(fnName), + Evaluate(exposeJS, nil), + Evaluate(expression, nil), + // Make it effective after navigation. + addScriptToEvaluateOnNewDocument(exposeJS), + addScriptToEvaluateOnNewDocument(expression), + ) if err != nil { return err } @@ -80,38 +47,65 @@ func Expose(fnName string, fn BindingFunc) ExposeAction { ListenTarget(ctx, func(ev interface{}) { switch ev := ev.(type) { case *runtime.EventBindingCalled: - var payload BindingCalledPayload + 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 != "cdpExposedFun" { + if payload.Type != "exposedFun" || payload.Name != fnName { return } - if payload.Name == fnName { - callFnName := deliverResult - result, err := fn(payload.Args) + result, err := fn(payload.Args) - if err != nil { - result = err.Error() - callFnName = deliverError - } + callback := "chromedpExposeFunc.deliverResult" + if err != nil { + result = err.Error() + callback = "chromedpExposeFunc.deliverError" + } - // Prevent the message from being processed by other functions - ev.Payload = "" + // 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, + ), + ) - go func() { - Run(ctx, CallFunctionOn(callFnName, 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 + }) +} diff --git a/expose_test.go b/expose_test.go index 04331ac2..55a920a4 100644 --- a/expose_test.go +++ b/expose_test.go @@ -29,9 +29,6 @@ func TestExpose(t *testing.T) { ctx, cancel := testAllocate(t, "") defer cancel() - // creates a new page. about:blank - Run(ctx) - // expose md5SumFunc function as md5 to browser current page and every frame if err := Run(ctx, Expose("md5", md5SumFunc)); err != nil { t.Fatal(err) @@ -68,7 +65,7 @@ func TestExpose(t *testing.T) { // 2. Navigate another page if err := Run(ctx, - Navigate(testdataDir+"/expose.html"), + Navigate(testdataDir+"/child1.html"), ); err != nil { t.Fatal(err) } diff --git a/js/expose.js b/js/expose.js index d3135816..a24663d6 100644 --- a/js/expose.js +++ b/js/expose.js @@ -1,44 +1,43 @@ -function deliverError(name, seq, message, stack) { - const error = new Error(message); - error.stack = stack; - window["CDP_BINDING_" + name].callbacks.get(seq).reject(error); - window["CDP_BINDING_" + name].callbacks.delete(seq); -} +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); + 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]; -function deliverResult(name, seq, result) { - window["CDP_BINDING_" + name].callbacks.get(seq).resolve(result); - window["CDP_BINDING_" + name].callbacks.delete(seq); -} + // 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' + ) + ); + } -function addTargetBinding(type, name) { - // This is the CDP binding. - window["CDP_BINDING_" + name] = window[name]; - - // We replace the CDP binding with a chromedp binding. - 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]; - // This is the chromedp binding. - const callChromedp = window["CDP_BINDING_" + name]; + binding.callbacks ??= new Map(); - if (callChromedp.callbacks == undefined) { - callChromedp.callbacks = new Map() - } - if (callChromedp.lastSeq == undefined) { - callChromedp.lastSeq = 0 - } + const seq = (binding.lastSeq ?? 0) + 1; + binding.lastSeq = seq; - const seq = callChromedp.lastSeq + 1 - callChromedp.lastSeq = seq; - - callChromedp(JSON.stringify({ type, name, seq, args })); + // Call the binding function to trigger runtime.EventBindingCalled. + binding(JSON.stringify({ type, name, seq, args })); - return new Promise((resolve, reject) => { - callChromedp.callbacks.set(seq, { resolve, reject }); - }); - }, - }); -} + return new Promise((resolve, reject) => { + binding.callbacks.set(seq, { resolve, reject }); + }); + }, + }); + }, +}; diff --git a/testdata/expose.html b/testdata/expose.html deleted file mode 100644 index 804aa5fe..00000000 --- a/testdata/expose.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - chromedp expose - - - - - From 9cbf5486e070c00e1345c28ad0214dbb196961c4 Mon Sep 17 00:00:00 2001 From: hillguo Date: Mon, 20 Feb 2023 14:26:05 +0800 Subject: [PATCH 10/12] expose the function to all frames --- expose.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/expose.go b/expose.go index e4ba8eda..7aba47fc 100644 --- a/expose.go +++ b/expose.go @@ -32,10 +32,11 @@ 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), - Evaluate(exposeJS, nil), - Evaluate(expression, nil), + evaluateOnAllFrames(exposeJS), + evaluateOnAllFrames(expression), // Make it effective after navigation. addScriptToEvaluateOnNewDocument(exposeJS), addScriptToEvaluateOnNewDocument(expression), @@ -109,3 +110,20 @@ func addScriptToEvaluateOnNewDocument(script string) Action { return err }) } + +func evaluateOnAllFrames(script string) Action { + 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) + }) +} From fb276c4b157a5ea75d808f578b21ef6c6ceaabe9 Mon Sep 17 00:00:00 2001 From: hillguo Date: Mon, 20 Feb 2023 21:07:33 +0800 Subject: [PATCH 11/12] fix closure bug && add exposeToAllFrames test case --- expose.go | 3 ++- expose_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/expose.go b/expose.go index 7aba47fc..9bb72353 100644 --- a/expose.go +++ b/expose.go @@ -118,8 +118,9 @@ func evaluateOnAllFrames(script string) Action { c.Target.frameMu.RLock() actions := make([]Action, 0, len(c.Target.execContexts)) for _, executionContextID := range c.Target.execContexts { + id := executionContextID actions = append(actions, Evaluate(script, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { - return p.WithContextID(executionContextID) + return p.WithContextID(id) })) } c.Target.frameMu.RUnlock() diff --git a/expose_test.go b/expose_test.go index 55a920a4..08c477a0 100644 --- a/expose_test.go +++ b/expose_test.go @@ -20,9 +20,60 @@ func base64EncodeFunc(args string) (string, error) { return base64.StdEncoding.EncodeToString([]byte(testString)), nil } +func echoFunc(str string) (string, error) { + return str, nil +} + const testString = "chromedp expose test" const testStringMd5 = "a93d69002a286b46c8aa114362afb7ac" const testStringBase64 = "Y2hyb21lZHAgZXhwb3NlIHRlc3Q=" +const testIFrameHTMLTitle = "page with an iframe" +const testFormHTMLTitle = "this is form title" + +func TestExposeToAllFrames(t *testing.T) { + // allocate browser + ctx, cancel := testAllocate(t, "iframe.html") + defer cancel() + + // expose echoFunc function as to browser current page and every frame + if err := Run(ctx, Expose("echo", echoFunc)); err != nil { + t.Fatal(err) + } + + c := FromContext(ctx) + + c.Target.frameMu.RLock() + executionContextIDs := make([]runtime.ExecutionContextID, 0, len(c.Target.execContexts)) + for _, executionContextID := range c.Target.execContexts { + executionContextIDs = append(executionContextIDs, executionContextID) + } + c.Target.frameMu.RUnlock() + + var res1 string + var res2 string + callEchoFunc := fmt.Sprintf(`%s(document.title);`, "echo") + for _, executionContextID := range executionContextIDs { + id := executionContextID + var res string + if err := Run(ctx, Evaluate(callEchoFunc, &res, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithContextID(id).WithAwaitPromise(true) + })); err != nil { + t.Fatal(err) + } + if len(res1) == 0 { + res1 = res + } else { + res2 = res + } + } + + // we expect res1 or res2 = testIFrameHTMLTitle or testFormHTMLTitle + if res1 == testIFrameHTMLTitle && res2 == testFormHTMLTitle || res1 == testFormHTMLTitle && res2 == testIFrameHTMLTitle { + // pass + } else { + t.Fatalf("res1: %s, res2: %s", res1, res2) + } +} func TestExpose(t *testing.T) { // allocate browser From d1c146ee11657b7f1138fb37bbd47ed3d8b94685 Mon Sep 17 00:00:00 2001 From: hillguo Date: Tue, 21 Feb 2023 10:20:38 +0800 Subject: [PATCH 12/12] modify note --- expose.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/expose.go b/expose.go index 9bb72353..31204c79 100644 --- a/expose.go +++ b/expose.go @@ -22,8 +22,8 @@ type ExposeAction Action // 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? -// 4. (iframe?) +// 3. The exposed function survives page navigation until the tab is closed. +// 4. It exports the function to all frames on the current page. // 5. Avoid exposing multiple funcs with the same name. // 6. Maybe you just need runtime.AddBinding. //