Compare commits

...

82 Commits

Author SHA1 Message Date
ad82438599 mod is changed 2019-04-19 22:11:04 +09:00
a0bba77505 name of project is changed 2019-04-19 22:06:04 +09:00
Daniel Martí
f606ca9e73 fix a couple of crashes with premature cancels
The first would fail as we'd decrement the WaitGroup before adding to
it, and the escond would fail with a nil pointer dereference panic.

Add tests to reproduce both, and fix them.
2019-04-18 15:07:33 +09:00
Daniel Martí
1a54253acd make vet happy with TestBrowserQuit
It's right that we were leaking a context here, resulting in a timer not
being stopped once the test finished.
2019-04-17 13:29:47 +09:00
Daniel Martí
958088f83b clarify NewContext's inheritance and cancellation
We have been developing this behavior over the past few weeks, but it
wasn't properly documented anywhere.

Fixes #303.
2019-04-17 13:25:09 +09:00
Daniel Martí
e4c16681d0 expose the default allocator options
For example, this can be useful if a user wants to simply add one flag
of their own, without otherwise messing with the default flags. In the
old codebase, they'd either have to build their own list from scratch,
or copy ours from source, needing to keep it in sync.
2019-04-17 13:23:43 +09:00
Daniel Martí
71ae9f7bbc clarify that Run won't work with an allocator context
We made the function error with ErrInvalidContext in that case, but the
godoc was still a bit ambiguous. Make it clearer to avoid confusion.

For #299.
2019-04-16 14:26:36 +09:00
Daniel Martí
ac47d6ba0e error in Run if passed an allocator context
The correct way to use Run is after NewContext. Using NewExecAllocator
only, which also gives a context, almost worked. This confused some
users into thinking it was a bug in chromedp.

Instead, error immediately with an invalid context error.

Fixes #299.
2019-04-14 19:56:09 +09:00
Daniel Martí
92a77355f6 README: remove obsolete TODO, add godoc examples 2019-04-09 16:21:33 +02:00
Daniel Martí
46982a1cac rework CancelError into Cancel
That way, cancelling a context while checking the error is much simpler.
The context data already holds onto the cancel func, so this requires no
internal changes.

This renders the Browser.Shutdown API obsolete, even though it doesn't
do exactly the same. If we want Cancel to do a proper shutdown action
before cancelling a browser context and killing the process, we could do
that change within the cancel logic.
2019-04-09 13:06:34 +02:00
Kenneth Shaw
e8122e4a26 Add WithDebugf() context option
Adds the high level WithDebugf() context option, and associated lower
level browser and dial options for setting a protocol wire debugger.
Additionally changes the conn.Conn.Read/Write implementations to be more
efficient, using direct easyjson.{Marshal,Unmarshal} calls and logging
to debug func when available.
2019-04-09 10:22:11 +02:00
Daniel Martí
b481eeac51 support fetching errors from cancellation
The API isn't very shiny, but it works. It doesn't matter that much, as
most users won't care about these errors.

Fixes #295.
2019-04-08 18:52:14 +02:00
Daniel Martí
b8efcf0691 support using BrowserOption in NewContext
While at it, remove the error return from BrowserOption, to make it
consistent with all the other option func types.

We don't have a good test for this feature yet, but at least we check
that it doesn't crash or error via an example.

Fixes #292.
2019-04-08 13:03:55 +02:00
Daniel Martí
a29b1ec1d6 deflake TestNavigate
For some reason, this test fails about half the time on Travis, but I
can't get it to fail even after stress-testing hundreds of concurrent
runs.

It might be because Travis is on a much older version of Chrome. We'll
fix that soon, by having chromedp select a specific version of Chrome.

For now, make it more conservative, not assuming that a Location after a
Navigate isn't racy.
2019-04-08 12:38:36 +02:00
Daniel Martí
11b3a5dc8f rename context.go to chromedp.go
And merge context_test.go with chromedp_test.go.

While at it, move the package godoc to chromedp.go, as that's now the
file named after the package.
2019-04-08 12:33:08 +02:00
Daniel Martí
d0484ed1c5 simplify the allocator API
Exposing NewAllocator and AllocatorOption was unnecessary, and it made
the API more complex to use and understand.

Instead, have users call NewExecAllocator directly. This removes some
code, and simplifies the examples and tests.
2019-04-08 12:10:59 +02:00
Daniel Martí
687cf6d766 wait for cleanup when cancelling the first context
We were doing this for extra open tabs, since NewContext was taking care
of detaching and closing the respective target.

And cancelling an entire allocator also properly waited for all the
resources, such as processes and temporary directories, to be cleaned
up.

However, this didn't work for a browser's first context; cancelling it
should wait for that one browser's resources to be cleaned up, but that
wasn't implemented. Do that, fixing the TODO in TestExecAllocator.
2019-04-07 23:40:29 +02:00
Daniel Martí
939d377090 avoid hanging when Chrome is closed separately
It's Run that actually starts a Browser, not NewContext. If the browser
is closed or crashes, the browser handler will fail to read from the
websocket, and its goroutines will stop.

However, the target handler's goroutines may not stop. The browser
handler uses a separate cancel function to stop itself when encountering
a websocket error, so that doesn't propagate to the original context
children, like the target handler.

To fix this, make it so that NewContext can keep the cancel function
around, for Run to use it in this scenario. And add a test case that
tests this very edge case, which used to time out before the fix.

Fixes #289.
2019-04-07 19:28:41 +02:00
Daniel Martí
b977e305d2 fix regression when using Run twice on the first ctx
We don't want to always set c.first, as that can change the field from
true to false.
2019-04-07 18:49:53 +02:00
Daniel Martí
c41ed01b6a close a page when cancelling its context
For all contexts except the first browser context, as in that case the
allocator and browser handler already take care of shutting down the
process and goroutines, respectively.

Fixes #293.
2019-04-07 14:22:07 +02:00
Daniel Martí
c313fa1c1d add TargetID to Target
This will be useful later on, for example to be able to close a target
(a page) once it gets cancelled.
2019-04-07 13:37:32 +02:00
Daniel Martí
b647c708b4 don't create an extra tab when starting a browser
Chrome already starts with a blank page, so use that for the first
target context instead of creating a new tab.

Add the first version of the Targets API, which is useful to test this
feature.

Fixes #291.
2019-04-07 01:18:15 +02:00
Daniel Martí
97e80a00d5 make NewAllocator's cancel func block on Wait
This way, the simple examples and tests don't need to do that
separately. Practically all users will want this cleanup work to be
synchronous, and practically all Go APIs are synchronous by default, so
this makes chromedp easier to use.
2019-04-06 22:13:40 +02:00
Daniel Martí
504561eab2 bump cdproto dep to ignore deprecated events
We no longer have to keep a list of deprecated events to avoid panics in
cdproto.
2019-04-06 01:11:43 +02:00
Kenneth Shaw
65a198c84e Generic code cleanup
Adding some comments, removing unused items, and renaming handler.go to
target.go to reflect the internal type name changes.
2019-04-03 09:03:41 +02:00
Daniel Martí
ece2b3ab92 give examples better names to appease vet 2019-04-02 13:51:47 +02:00
Daniel Martí
896fbe60c2 consistently use %02d for subtest index names
"test" as part of the name is redundant, and spaces aren't recommended.

Add a padding for two digits, so that tests with more than a handful of
subtests still print in a nice way.
2019-04-01 19:58:01 +01:00
Daniel Martí
e482cdfc4d clean up uses of Run in the tests
Many consecutive calls to Run can be collapsed into a single call. While
at it, make the error handling style more consistent. Overall removes 70
lines of repetitive code.
2019-04-01 19:55:40 +01:00
Daniel Martí
120628a01c fix data race when spawning tabs concurrently
This fixes the data race uncovered by the recent refactor to run all
tests as tabs under the same browser.

The problem was that a write on the pages map could be done from the
goroutine calling NewContext to create a new map, while other goroutines
could similarly read or write the same map.

Instead of adding a lock around the map, make one of the Browser's
goroutines be the sole user of the map. To make that extra obvious and
avoid potential races in the future, declare the map inside the
goroutine's scope.

For some reason, this makes the Attributes tests flakier than before.
For now, add short sleeps; we can investigate that separately, now that
the data races are gone.
2019-04-01 19:31:05 +01:00
Daniel Martí
7c8529b914 give up on refactoring TestFileUpload
The reason that t.Parallel broke the tests was because the parent test
must finish before the parallel subtests can start. So, we'd be closing
the httptest server and removing the tmpfile before any of the parallel
subtests even began.

We could refactor this to make the subtests parallel, but they're only
two, and the parent is already parallel, so it's not worth the effort.
See the added comment.
2019-04-01 17:26:11 +01:00
Daniel Martí
41e913e571 various minor cleanups
Remove the log option lines from testAllocate; right now, we don't have
these options for Target, and Target doesn't log much anyway. We can
always revisit this in the future.

While at it, simplify some code.
2019-04-01 17:12:17 +01:00
Daniel Martí
ad8809efb7 use time.NewTimer instead of time.After in Sleep
To fix a potential temporary goroutine leak; see the added comment. The
leaked goroutine would eventually stop once the timer is fired, but
until then, the goroutine could be unnecessarily left around.
2019-04-01 17:03:35 +01:00
Daniel Martí
0d568ec2a4 change Run to allow many actions
This can simplify some common use cases, like running a few actions
directly, or running no actions at all. It's also an almost entirely
backwards compatible change, as all Run call sites should continue to
compile and work.

Leave Tasks, as it can still be useful for functions to return complex
sequences of actions as a single Action.
2019-04-01 16:59:23 +01:00
Daniel Martí
fb23c1750a fix data races in table-driven parallel subtests
t.Parallel effectively fires off a goroutine, so we can't use the test
range variable directly. That can result in different subtests using the
same test case data, causing sporadic failures, or some test cases
rarely being actually tested.

This was uncovered while stress-testing the test suite for an unrelated
refactor.

While at it, one test case in TestMouseClickNode was incorrect.
contextmenu fires on a right click, so ModifierNone won't fire it. This
wasn't caught before, as this test case was almost never ran. After the
data race fix, the test case failed consistently, before being fixed.
2019-04-01 16:48:49 +01:00
Daniel Martí
d73caffcd0 make gofumpt happy
Just a stricter gofmt; see mvdan.cc/gofumpt.
2019-04-01 16:43:03 +01:00
Daniel Martí
1decbccd74 store a Target pointer directly in Context
That way, we avoid the racy map access via Browser.executorForTarget. If
a context is attached to a target, the Target field must be non-nil.

The Browser.pages map is still racy, since multiple tabs can be created
concurrently; we'll fix this other data race in another commit.
2019-04-01 14:31:11 +01:00
Daniel Martí
117274bc5d run all tests as separate tabs on one browser
This vastly speeds up 'go test' on my laptop from ~10s to ~3s, as we
save a lot of time spinning up new Chrome browser processes.

In practice, each tab is a separate process anyway, but there's a lot of
added overhead if we're firing up the entire browser, particularly with
an empty user data dir.

This makes 'go test' racy now, as Browser doesn't support creating tabs
concurrently right now. Follow-up commits will fix that, with the help
of 'go test -race' after this commit.
2019-04-01 14:25:24 +01:00
Daniel Martí
661ef78880 don't crash when loading pages with iframes
We broke this in the refactor because of a nil pointer dereference, but
we didn't catch that as none of the tests loaded a page with an iframe.
That is, a page with multiple frames.

Add such a test, and fix the bug by creating an almost-empty frame when
we start receiving events about a new frame before it's navigated to.
2019-04-01 12:18:16 +01:00
Daniel Martí
8ff2971fc5 test cancelling an entire Allocator directly
To ensure that it propagates to each browser correctly.
2019-04-01 12:18:16 +01:00
Daniel Martí
a0a36956a8 add support for opening multiple tabs
On a single browser, that is. And port the example from _example,
proving that it works.
2019-04-01 12:18:16 +01:00
Daniel Martí
2b925df0fb rewrite TestReload without a sleep 2019-04-01 12:18:16 +01:00
Daniel Martí
f742f327a7 speed up the screenshot tests, and test the images
Using a smaller viewport speeds up both tests, and lets us know what
dimensions to expect in TestCaptureScreenshot.

For TestScreenshot, we can know what dimensions to expect in advance, as
we have the images in testdata.

'go test -run Screenshot' goes from ~0.9s to ~0.5s on my machine.

Finally, don't run ExampleTitle as part of 'go test', as it's slow.
2019-04-01 12:18:16 +01:00
Daniel Martí
0e92de5e65 make ExampleExecAllocatorOption less flaky
The Default folder is created asynchronously to Chrome starting to
listen on the debugging protocol port. So we can't expect it to exist.

Instead, base the example on DevToolsActivePort, which we can rely on.
2019-04-01 12:18:16 +01:00
Daniel Martí
32d4bae280 clean up various pieces of the API
First, collapse Browser.Start with NewBrowser. There's no reason to
split them up.

Second, unexport Browser.userDataDir, since it's only needed for a test.
It's also a bad precedent, as only the ExecAllocator will control the
user data directory.

Third, export Context.Browser, since we were already exporting
Context.Allocator.

Finally, remove the Executor interface, a duplicate of cdp.Executor.
2019-04-01 12:18:16 +01:00
Daniel Martí
a93c63124f add some missing godocs on allocators and Run 2019-04-01 12:18:16 +01:00
Daniel Martí
b136a6267e remove Context's Wait method for now
All it did was wait on the entire allocator, which is confusing. From
the user's perspective, this wait method should instead wait for the
resources for its own browser, and not any other browsers sharing the
same allocator.

We haven't decided how to integrate that into our API, so simply replace
it with Allocator.Wait.
2019-04-01 12:18:16 +01:00
Daniel Martí
e698c943b3 make the simple and allocator examples runnable
This way, not only do we ensure that they always build, they're also
verified as part of 'go test'.
2019-04-01 12:18:16 +01:00
Daniel Martí
5fb1c07412 start running the tests on CI again
Now that they're both faster and more reliable than before the refactor.

On my laptop, 'go test' now consistently takes ~10s, and I haven't found
any flakes in the past couple of days.
2019-04-01 12:18:16 +01:00
Daniel Martí
2ca3ea3591 avoid a second map in ExecAllocator
Instead, write the args list directly.
2019-04-01 12:18:16 +01:00
Daniel Martí
6fb5264bbd use DisableGPU in the tests
On my i5-8350U, the option takes 'go test' from ~11s to ~10s, from a
manual look over a few runs.
2019-04-01 12:18:16 +01:00
Daniel Martí
da4ac414ed get rid of all sleeps in tests
The navigate sleeps can be replaced by appropriate wait actions.

Some other tests don't need any sleeps at all. This might be because
work is done synchronously now; I haven't been able to get test flakes
after hundreds of test runs with flags like -parallel=32 -count=200.
2019-04-01 12:18:16 +01:00
Daniel Martí
7c1a9fbf3e get rid of all exceptions
We hadn't noticed a few uncaught exceptions being received from the
browser, because the events were ignored. Start printing them via the
error logger.

The ones we were getting were caused by testAllocate running Navigate
actions when the path argument was empty. Navigating to "testdata/"
causes JS exceptions, as it's not a valid page.

Instead, leave the new target pointing at a blank document.
2019-04-01 12:18:16 +01:00
Daniel Martí
c109f6ebfd use consistent context.Context var names 2019-04-01 12:18:16 +01:00
Daniel Martí
61f0a8da68 make some linters a bit happier 2019-04-01 12:18:16 +01:00
Daniel Martí
92bfcc3c8d collapse a few Navigate actions in the tests 2019-04-01 12:18:16 +01:00
Daniel Martí
24decf54d3 remove top level frame mutex from Target
Now that it handles events synchronously, there's no need to worry about
concurrent field accesses.
2019-04-01 12:18:16 +01:00
Daniel Martí
81a48280ef route all communication via the browser
Use a single websocket connection per browser, removing the need for an
extra websocket connection per target.

This is thanks to the Target.sendMessageToTarget command to send
messages to each target, and the Target.receivedMessageFromTarget event
to receive messages back.

The browser handles activity via a single worker goroutine, and the same
technique is used for each target. This means that commands and events
are dealt with in order, and we can do away with some complexity like
mutexes and extra go statements.
2019-04-01 12:18:16 +01:00
Daniel Martí
3d3bf22ccc start the chromedp v2 refactor
First, we want all of the functionality in a single package; this means
collapsing whatever is useful into the root chromedp package.

The runner package is being replaced by the Allocator interface, with a
default implementation which starts browser processes.

The client package doesn't really have a place in the new design. The
context, allocator, and browser types will handle the connection with
each browser.

Finally, the new API is context-based, hence the addition of context.go.
The tests have been modified to build and run against the new API.
2019-04-01 12:17:28 +01:00
xinglong
5aca12cc3e send messages with select to avoid deadlocks
Fixes #287.
2019-04-01 10:35:07 +01:00
Daniel Martí
e9aa66f87e fix build breakage with newer cdproto versions
This isn't strictly necessary, as one can always build with the earlier
cdproto version specified in go.mod. However, many people still install
chromedp in GOPATH via 'go get -u', so this workaround makes life easier
for a lot of developers.

Fixes #285.
2019-03-28 21:45:27 +00:00
Killian Brackey
39bd95c850 Clarified SIGTERM in shutdown comments 2019-03-02 14:20:46 +00:00
Killian Brackey
b61de69d62 Added SIGTERM to linux systems on shutdown
Closes https://github.com/chromedp/chromedp/issues/274
2019-03-02 14:20:46 +00:00
Daniel Martí
4cc9890745 add a simple issue template
For now, all it asks for is a few versions, and the typical three
questions to understand a bug.
2019-02-22 00:13:27 +01:00
Daniel Martí
37d13f2933 update all mod dependencies 2019-02-21 23:43:53 +01:00
Daniel Martí
26c9acb5b1 avoid ctx.Done() goroutine leak in Selector.run
As spotted in #162 by a contributor, if the context is done before the
Selector.run caller has received from the channel, the spawned goroutine
may leak if blocked on a send.
2019-02-21 17:58:08 +01:00
Daniel Martí
811d6d54d3 don't run TestFileUpload subtests in parallel
Turns out that these subtests are the only pair which cannot run in
parallel with each other. Undo that change and add a TODO. This should
fix the CI failures.

While at it, remove an unnecessary testAllocate line.
2019-02-21 17:22:40 +01:00
Daniel Martí
4c16288502 skip the error log in TestAllocatePortInUse
We know we're going to see an error, so don't log it too:

	$ go test -run TestAllocatePortInUse
	2019/02/21 17:11:34 ERROR: pool could not allocate runner ...
	PASS
	ok      github.com/chromedp/chromedp    0.004s
2019-02-21 17:13:38 +01:00
Daniel Martí
da4f783362 make all tests run in parallel
The subtests were almost all marked as parallel, but that's not enough.
That only makes the subtests run in parallel with other subtests within
the same tests, not with any other test.

Since none of the tests make use of globals nor require the entire
program to themselves, properly run all the tests in parallel.

Speeds up 'go test' on my 8-core laptop from an average of ~130s to an
average of ~50s. Many tests hit timeouts and have sleeps, so we want to
avoid running those sequentially whenever possible.
2019-02-21 13:56:54 +01:00
Daniel Martí
5ca52f3e1b use runner.LookChromeNames in TestMain
It supports alternative names for Chrome such as chromium, as well as
extra names to look for like headless-shell.

Also swap the os.Getenv logic, so that we only do the exec.LookPath work
if the env var is unset.
2019-02-21 13:53:05 +01:00
zhongjiajia
5dc1e0f3af use buffered chan for h.detached 2019-02-20 13:12:41 +01:00
Bob Potter
85ecf4f31f chromedp: fix SetHandlerByID
Don't fall through and return an error if we found a handler with a
matching ID.
2019-02-20 13:01:21 +01:00
Daniel Martí
7f54f3f93c CI: test on 1.11.x instead of tip
tip is rather unstable, so we shouldn't block PRs if it happens to break
our build or tests.

While at it, run 'go mod tidy' with the latest tip version.
2019-01-14 10:38:19 +00:00
Daniel Martí
98d4b0de6e pool: error quickly if we find a port in use
Before the fix, the added test would give a Pool.Allocate error like:

	pool could not connect to 9000: timeout waiting for initial target

The actual underlying error, which can only be seen if one inspects
chrome's stderr, is that it failed to bind to the debugging protocol
port if it was already in use.

This is of course an issue with the environment that chromedp is being
run under, since it was given a port range that wasn't available.
However, the confusing error can lead to developers wasting their time
instead of spotting the error quickly.

Unfortunately, there doesn't seem to be a way to have Chrome exit
immediately if it can't bind to the given port. So, instead of relying
on it, check if the current process can bind to the port first.

Add a test too, where we grab the first port in the pool range, and
check that we get an error that's not confusing.

Fixes #253.
2018-12-01 11:54:16 +00:00
Kenneth Shaw
bf52fed0d3 Fixing windows build issue 2018-07-18 06:19:22 +07:00
Kenneth Shaw
34591780d9 Updating dependencies 2018-07-13 12:49:42 +07:00
Kenneth Shaw
53015e7d81 Changing Debugging => DevTools 2018-07-13 12:46:30 +07:00
Kenneth Shaw
74e379587b Minor code cleanup to client package before rewrite 2018-07-13 11:35:03 +07:00
Kenneth Shaw
4ae10864e4 Adding client API changes prior to package rewrite 2018-07-13 11:24:37 +07:00
Kenneth Shaw
d413f67302 Minor cleanup to client/gen.go 2018-07-13 11:00:35 +07:00
Kenneth Shaw
622c90c82c Cleaning up runner API prior to package rewrite 2018-07-13 10:57:20 +07:00
Kenneth Shaw
e051c4a982 Removing errors dependency in client and runner packages 2018-07-13 09:28:45 +07:00
Kenneth Shaw
0406fa8a8a Fixing issue with kb 2018-07-10 19:51:58 +07:00
46 changed files with 2480 additions and 4007 deletions

15
.github/ISSUE_TEMPLATE vendored Normal file
View File

@ -0,0 +1,15 @@
#### What versions are you running?
<pre>
$ go list -m git.loafle.net/commons_go/chromedp
$ chromium --version
$ go version
</pre>
#### What did you do?
#### What did you expect to see?
#### What did you see instead?

View File

@ -1,14 +1,11 @@
language: go
go:
- 1.10.x
- tip
- 1.12.x
addons:
apt:
chrome: stable
before_install:
- go get github.com/mattn/goveralls golang.org/x/vgo
script:
- export CHROMEDP_TEST_RUNNER=google-chrome-stable
- export CHROMEDP_DISABLE_GPU=true
- vgo test -v -coverprofile=coverage.out
- goveralls -service=travis-ci -coverprofile=coverage.out
- go test -v ./...

108
README.md
View File

@ -1,118 +1,34 @@
# About chromedp [![Build Status][1]][2] [![Coverage Status][3]][4]
Package chromedp is a faster, simpler way to drive browsers in Go using the
[Chrome Debugging Protocol][5] (for Chrome, Edge, Safari, etc) without external
dependencies (ie, Selenium, PhantomJS, etc).
**NOTE:** chromedp's API is currently unstable, and may change at a moments
notice. There are likely extremely bad bugs lurking in this code. **CAVEAT USER**.
Package chromedp is a faster, simpler way to drive browsers supporting the
[Chrome DevTools Protocol][5] in Go using the without external dependencies
(ie, Selenium, PhantomJS, etc).
## Installing
Install in the usual way:
Install in the usual Go way:
```sh
go get -u github.com/chromedp/chromedp
go get -u git.loafle.net/commons_go/chromedp
```
## Using
Below is a simple Google search performed using chromedp (taken from
[examples/simple][6]):
This example shows logic for a simple search for a known website, clicking on
the right link, and then taking a screenshot of a specific element on the
loaded page and saving that to a local file on disk.
```go
// Command simple is a chromedp example demonstrating how to do a simple google
// search.
package main
import (
"context"
"fmt"
"io/ioutil"
"log"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/chromedp"
)
func main() {
var err error
// create context
ctxt, cancel := context.WithCancel(context.Background())
defer cancel()
// create chrome instance
c, err := chromedp.New(ctxt, chromedp.WithLog(log.Printf))
if err != nil {
log.Fatal(err)
}
// run task list
var site, res string
err = c.Run(ctxt, googleSearch("site:brank.as", "Home", &site, &res))
if err != nil {
log.Fatal(err)
}
// shutdown chrome
err = c.Shutdown(ctxt)
if err != nil {
log.Fatal(err)
}
// wait for chrome to finish
err = c.Wait()
if err != nil {
log.Fatal(err)
}
log.Printf("saved screenshot from search result listing `%s` (%s)", res, site)
}
func googleSearch(q, text string, site, res *string) chromedp.Tasks {
var buf []byte
sel := fmt.Sprintf(`//a[text()[contains(., '%s')]]`, text)
return chromedp.Tasks{
chromedp.Navigate(`https://www.google.com`),
chromedp.WaitVisible(`#hplogo`, chromedp.ByID),
chromedp.SendKeys(`#lst-ib`, q+"\n", chromedp.ByID),
chromedp.WaitVisible(`#res`, chromedp.ByID),
chromedp.Text(sel, res),
chromedp.Click(sel),
chromedp.WaitNotVisible(`.preloader-content`, chromedp.ByQuery),
chromedp.WaitVisible(`a[href*="twitter"]`, chromedp.ByQuery),
chromedp.Location(site),
chromedp.ScrollIntoView(`.banner-section.third-section`, chromedp.ByQuery),
chromedp.Sleep(2 * time.Second), // wait for animation to finish
chromedp.Screenshot(`.banner-section.third-section`, &buf, chromedp.ByQuery),
chromedp.ActionFunc(func(context.Context, cdp.Executor) error {
return ioutil.WriteFile("screenshot.png", buf, 0644)
}),
}
}
```
## Examples
Please see the [examples][6] project for more examples. Please refer to the
[GoDoc API listing][7] for a summary of the API and Actions.
[GoDoc API listing][7] for a summary of the API and Actions, which also contains
a few simple and runnable examples.
## Resources
* [chromedp: A New Way to Drive the Web][8] - GopherCon SG 2017 talk
* [Chrome DevTools Protocol][5] - Chrome Debugging Protocol Domain documentation
* [Chrome DevTools Protocol][5] - Chrome DevTools Protocol Domain documentation
* [chromedp examples][6] - various `chromedp` examples
* [`github.com/chromedp/cdproto`][9] - GoDoc listing for the CDP domains used by `chromedp`
* [`github.com/chromedp/cdproto-gen`][10] - tool used to generate `cdproto`
* [`github.com/chromedp/chromedp-proxy`][11] - a simple CDP proxy for logging/debugging CDP clients and browser instances
* [`git.loafle.net/commons_go/chromedp-proxy`][11] - a simple CDP proxy for logging CDP clients and browsers
## TODO
* Move timeouts to context (defaults)
* Implement more query selector options (allow over riding context timeouts)
* Contextual actions for "dry run" (or via an accumulator?)
* Network loader / manager
@ -124,8 +40,8 @@ Please see the [examples][6] project for more examples. Please refer to the
[4]: https://coveralls.io/github/chromedp/chromedp?branch=master
[5]: https://chromedevtools.github.io/devtools-protocol/
[6]: https://github.com/chromedp/examples
[7]: https://godoc.org/github.com/chromedp/chromedp
[7]: https://godoc.org/git.loafle.net/commons_go/chromedp
[8]: https://www.youtube.com/watch?v=_7pWCg94sKw
[9]: https://godoc.org/github.com/chromedp/cdproto
[10]: https://github.com/chromedp/cdproto-gen
[11]: https://github.com/chromedp/chromedp-proxy
[11]: https://git.loafle.net/commons_go/chromedp-proxy

View File

@ -18,8 +18,8 @@ type Action interface {
type ActionFunc func(context.Context, cdp.Executor) error
// Do executes the func f using the provided context and frame handler.
func (f ActionFunc) Do(ctxt context.Context, h cdp.Executor) error {
return f(ctxt, h)
func (f ActionFunc) Do(ctx context.Context, h cdp.Executor) error {
return f(ctx, h)
}
// Tasks is a sequential list of Actions that can be used as a single Action.
@ -27,12 +27,12 @@ type Tasks []Action
// Do executes the list of Actions sequentially, using the provided context and
// frame handler.
func (t Tasks) Do(ctxt context.Context, h cdp.Executor) error {
func (t Tasks) Do(ctx context.Context, h cdp.Executor) error {
// TODO: put individual task timeouts from context here
for _, a := range t {
// ctxt, cancel = context.WithTimeout(ctxt, timeout)
// ctx, cancel = context.WithTimeout(ctx, timeout)
// defer cancel()
if err := a.Do(ctxt, h); err != nil {
if err := a.Do(ctx, h); err != nil {
return err
}
}
@ -46,12 +46,15 @@ func (t Tasks) Do(ctxt context.Context, h cdp.Executor) error {
// be marked for deprecation in the future, after the remaining Actions have
// been able to be written/tested.
func Sleep(d time.Duration) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
// Don't use time.After, to avoid a temporary goroutine leak if
// ctx is cancelled before the timer fires.
t := time.NewTimer(d)
select {
case <-time.After(d):
case <-ctxt.Done():
return ctxt.Err()
case <-t.C:
case <-ctx.Done():
t.Stop()
return ctx.Err()
}
return nil
})

288
allocate.go Normal file
View File

@ -0,0 +1,288 @@
package chromedp
import (
"bufio"
"context"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strings"
"sync"
)
// An Allocator is responsible for creating and managing a number of browsers.
//
// This interface abstracts away how the browser process is actually run. For
// example, an Allocator implementation may reuse browser processes, or connect
// to already-running browsers on remote machines.
type Allocator interface {
// Allocate creates a new browser. It can be cancelled via the provided
// context, at which point all the resources used by the browser (such
// as temporary directories) will be freed.
Allocate(context.Context, ...BrowserOption) (*Browser, error)
// Wait blocks until an allocator has freed all of its resources.
// Cancelling the allocator context will already perform this operation,
// so normally there's no need to call Wait directly.
Wait()
}
// setupExecAllocator is similar to NewExecAllocator, but it allows NewContext
// to create the allocator without the unnecessary context layer.
func setupExecAllocator(opts ...ExecAllocatorOption) *ExecAllocator {
ep := &ExecAllocator{
initFlags: make(map[string]interface{}),
}
for _, o := range opts {
o(ep)
}
if ep.execPath == "" {
ep.execPath = findExecPath()
}
return ep
}
// DefaultExecAllocatorOptions are the ExecAllocator options used by NewContext
// if the given parent context doesn't have an allocator set up.
var DefaultExecAllocatorOptions = []ExecAllocatorOption{
NoFirstRun,
NoDefaultBrowserCheck,
Headless,
}
// NewExecAllocator creates a new context set up with an ExecAllocator, suitable
// for use with NewContext.
func NewExecAllocator(parent context.Context, opts ...ExecAllocatorOption) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
c := &Context{Allocator: setupExecAllocator(opts...)}
ctx = context.WithValue(ctx, contextKey{}, c)
cancelWait := func() {
cancel()
c.Allocator.Wait()
}
return ctx, cancelWait
}
// ExecAllocatorOption is a exec allocator option.
type ExecAllocatorOption func(*ExecAllocator)
// ExecAllocator is an Allocator which starts new browser processes on the host
// machine.
type ExecAllocator struct {
execPath string
initFlags map[string]interface{}
wg sync.WaitGroup
}
// Allocate satisfies the Allocator interface.
func (p *ExecAllocator) Allocate(ctx context.Context, opts ...BrowserOption) (*Browser, error) {
c := FromContext(ctx)
if c == nil {
return nil, ErrInvalidContext
}
var args []string
for name, value := range p.initFlags {
switch value := value.(type) {
case string:
args = append(args, fmt.Sprintf("--%s=%s", name, value))
case bool:
if value {
args = append(args, fmt.Sprintf("--%s", name))
}
default:
return nil, fmt.Errorf("invalid exec pool flag")
}
}
removeDir := false
dataDir, ok := p.initFlags["user-data-dir"].(string)
if !ok {
tempDir, err := ioutil.TempDir("", "chromedp-runner")
if err != nil {
return nil, err
}
args = append(args, "--user-data-dir="+tempDir)
dataDir = tempDir
removeDir = true
}
args = append(args, "--remote-debugging-port=0")
var cmd *exec.Cmd
p.wg.Add(1) // for the entire allocator
c.wg.Add(1) // for this browser's root context
go func() {
<-ctx.Done()
// First wait for the process to be finished.
if cmd != nil {
// TODO: do we care about this error in any scenario? if
// the user cancelled the context and killed chrome,
// this will most likely just be "signal: killed", which
// isn't interesting.
cmd.Wait()
}
// Then delete the temporary user data directory, if needed.
if removeDir {
if err := os.RemoveAll(dataDir); c.cancelErr == nil {
c.cancelErr = err
}
}
p.wg.Done()
c.wg.Done()
}()
// force the first page to be blank, instead of the welcome page
// TODO: why isn't --no-first-run enough?
args = append(args, "about:blank")
cmd = exec.CommandContext(ctx, p.execPath, args...)
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
// Pick up the browser's websocket URL from stderr.
wsURL := ""
scanner := bufio.NewScanner(stderr)
prefix := "DevTools listening on"
for scanner.Scan() {
line := scanner.Text()
if s := strings.TrimPrefix(line, prefix); s != line {
wsURL = strings.TrimSpace(s)
break
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
stderr.Close()
browser, err := NewBrowser(ctx, wsURL, opts...)
if err != nil {
return nil, err
}
browser.process = cmd.Process
browser.userDataDir = dataDir
return browser, nil
}
// Wait satisfies the Allocator interface.
func (p *ExecAllocator) Wait() {
p.wg.Wait()
}
// ExecPath returns an ExecAllocatorOption which uses the given path to execute
// browser processes. The given path can be an absolute path to a binary, or
// just the name of the program to find via exec.LookPath.
func ExecPath(path string) ExecAllocatorOption {
return func(p *ExecAllocator) {
if fullPath, _ := exec.LookPath(path); fullPath != "" {
// Convert to an absolute path if possible, to avoid
// repeated LookPath calls in each Allocate.
path = fullPath
}
p.execPath = path
}
}
// findExecPath tries to find the Chrome browser somewhere in the current
// system. It performs a rather agressive search, which is the same in all
// systems. That may make it a bit slow, but it will only be run when creating a
// new ExecAllocator.
func findExecPath() string {
for _, path := range [...]string{
// Unix-like
"headless_shell",
"headless-shell",
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"/usr/bin/google-chrome",
// Windows
"chrome",
"chrome.exe", // in case PATHEXT is misconfigured
`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
// Mac
`/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
} {
found, err := exec.LookPath(path)
if err == nil {
return found
}
}
// Fall back to something simple and sensible, to give a useful error
// message.
return "google-chrome"
}
// Flag is a generic command line option to pass a flag to Chrome. If the value
// is a string, it will be passed as --name=value. If it's a boolean, it will be
// passed as --name if value is true.
func Flag(name string, value interface{}) ExecAllocatorOption {
return func(p *ExecAllocator) {
p.initFlags[name] = value
}
}
// UserDataDir is the command line option to set the user data dir.
//
// Note: set this option to manually set the profile directory used by Chrome.
// When this is not set, then a default path will be created in the /tmp
// directory.
func UserDataDir(dir string) ExecAllocatorOption {
return Flag("user-data-dir", dir)
}
// ProxyServer is the command line option to set the outbound proxy server.
func ProxyServer(proxy string) ExecAllocatorOption {
return Flag("proxy-server", proxy)
}
// WindowSize is the command line option to set the initial window size.
func WindowSize(width, height int) ExecAllocatorOption {
return Flag("window-size", fmt.Sprintf("%d,%d", width, height))
}
// UserAgent is the command line option to set the default User-Agent
// header.
func UserAgent(userAgent string) ExecAllocatorOption {
return Flag("user-agent", userAgent)
}
// NoSandbox is the Chrome comamnd line option to disable the sandbox.
func NoSandbox(p *ExecAllocator) {
Flag("no-sandbox", true)(p)
}
// NoFirstRun is the Chrome comamnd line option to disable the first run
// dialog.
func NoFirstRun(p *ExecAllocator) {
Flag("no-first-run", true)(p)
}
// NoDefaultBrowserCheck is the Chrome comamnd line option to disable the
// default browser check.
func NoDefaultBrowserCheck(p *ExecAllocator) {
Flag("no-default-browser-check", true)(p)
}
// Headless is the command line option to run in headless mode.
func Headless(p *ExecAllocator) {
Flag("headless", true)(p)
}
// DisableGPU is the command line option to disable the GPU process.
func DisableGPU(p *ExecAllocator) {
Flag("disable-gpu", true)(p)
}

76
allocate_test.go Normal file
View File

@ -0,0 +1,76 @@
package chromedp
import (
"context"
"os"
"testing"
)
func TestExecAllocator(t *testing.T) {
t.Parallel()
allocCtx, cancel := NewExecAllocator(context.Background(), allocOpts...)
defer cancel()
// TODO: test that multiple child contexts are run in different
// processes and browsers.
taskCtx, cancel := NewContext(allocCtx)
defer cancel()
want := "insert"
var got string
if err := Run(taskCtx,
Navigate(testdataDir+"/form.html"),
Text("#foo", &got, ByID),
); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("wanted %q, got %q", want, got)
}
cancel()
tempDir := FromContext(taskCtx).Browser.userDataDir
if _, err := os.Lstat(tempDir); !os.IsNotExist(err) {
t.Fatalf("temporary user data dir %q not deleted", tempDir)
}
}
func TestExecAllocatorCancelParent(t *testing.T) {
t.Parallel()
allocCtx, allocCancel := NewExecAllocator(context.Background(), allocOpts...)
defer allocCancel()
// TODO: test that multiple child contexts are run in different
// processes and browsers.
taskCtx, _ := NewContext(allocCtx)
if err := Run(taskCtx); err != nil {
t.Fatal(err)
}
// Canceling the pool context should stop all browsers too.
allocCancel()
tempDir := FromContext(taskCtx).Browser.userDataDir
if _, err := os.Lstat(tempDir); !os.IsNotExist(err) {
t.Fatalf("temporary user data dir %q not deleted", tempDir)
}
}
func TestSkipNewContext(t *testing.T) {
ctx, cancel := NewExecAllocator(context.Background(), allocOpts...)
defer cancel()
// Using the allocator context directly (without calling NewContext)
// should be an immediate error.
err := Run(ctx, Navigate(testdataDir+"/form.html"))
want := ErrInvalidContext
if err != want {
t.Fatalf("want error to be %q, got %q", want, err)
}
}

321
browser.go Normal file
View File

@ -0,0 +1,321 @@
package chromedp
import (
"context"
"encoding/json"
"log"
"os"
"sync/atomic"
"github.com/chromedp/cdproto"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/target"
)
// Browser is the high-level Chrome DevTools Protocol browser manager, handling
// the browser process runner, WebSocket clients, associated targets, and
// network, page, and DOM events.
type Browser struct {
conn Transport
// next is the next message id.
next int64
// tabQueue is the queue used to create new target handlers, once a new
// tab is created and attached to. The newly created Target is sent back
// via tabResult.
tabQueue chan newTab
tabResult chan *Target
// cmdQueue is the outgoing command queue.
cmdQueue chan cmdJob
// logging funcs
logf func(string, ...interface{})
errf func(string, ...interface{})
dbgf func(string, ...interface{})
// The optional fields below are helpful for some tests.
// process can be initialized by the allocators which start a process
// when allocating a browser.
process *os.Process
// userDataDir can be initialized by the allocators which set up user
// data dirs directly.
userDataDir string
}
type newTab struct {
targetID target.ID
sessionID target.SessionID
}
type cmdJob struct {
msg *cdproto.Message
resp chan *cdproto.Message
}
// NewBrowser creates a new browser.
func NewBrowser(ctx context.Context, urlstr string, opts ...BrowserOption) (*Browser, error) {
b := &Browser{
tabQueue: make(chan newTab, 1),
tabResult: make(chan *Target, 1),
cmdQueue: make(chan cmdJob),
logf: log.Printf,
}
// apply options
for _, o := range opts {
o(b)
}
// ensure errf is set
if b.errf == nil {
b.errf = func(s string, v ...interface{}) { b.logf("ERROR: "+s, v...) }
}
// dial
var err error
b.conn, err = DialContext(ctx, ForceIP(urlstr), WithConnDebugf(b.dbgf))
if err != nil {
return nil, err
}
go b.run(ctx)
return b, nil
}
func (b *Browser) newExecutorForTarget(ctx context.Context, targetID target.ID, sessionID target.SessionID) *Target {
if targetID == "" {
panic("empty target ID")
}
if sessionID == "" {
panic("empty session ID")
}
b.tabQueue <- newTab{targetID, sessionID}
return <-b.tabResult
}
func (b *Browser) Execute(ctx context.Context, method string, params json.Marshaler, res json.Unmarshaler) error {
paramsMsg := emptyObj
if params != nil {
var err error
if paramsMsg, err = json.Marshal(params); err != nil {
return err
}
}
id := atomic.AddInt64(&b.next, 1)
ch := make(chan *cdproto.Message, 1)
b.cmdQueue <- cmdJob{
msg: &cdproto.Message{
ID: id,
Method: cdproto.MethodType(method),
Params: paramsMsg,
},
resp: ch,
}
select {
case msg := <-ch:
switch {
case msg == nil:
return ErrChannelClosed
case msg.Error != nil:
return msg.Error
case res != nil:
return json.Unmarshal(msg.Result, res)
}
case <-ctx.Done():
return ctx.Err()
}
return nil
}
type tabEvent struct {
sessionID target.SessionID
msg *cdproto.Message
}
func (b *Browser) run(ctx context.Context) {
defer b.conn.Close()
cancel := FromContext(ctx).cancel
// tabEventQueue is the queue of incoming target events, to be routed by
// their session ID.
tabEventQueue := make(chan tabEvent, 1)
// resQueue is the incoming command result queue.
resQueue := make(chan *cdproto.Message, 1)
// This goroutine continuously reads events from the websocket
// connection. The separate goroutine is needed since a websocket read
// is blocking, so it cannot be used in a select statement.
go func() {
for {
msg, err := b.conn.Read()
if err != nil {
// If the websocket failed, most likely Chrome
// was closed or crashed. Cancel the entire
// Browser context to stop all activity.
cancel()
return
}
if msg.Method == cdproto.EventRuntimeExceptionThrown {
ev := new(runtime.EventExceptionThrown)
if err := json.Unmarshal(msg.Params, ev); err != nil {
b.errf("%s", err)
continue
}
b.errf("%+v\n", ev.ExceptionDetails)
continue
}
var sessionID target.SessionID
if msg.Method == cdproto.EventTargetReceivedMessageFromTarget {
event := new(target.EventReceivedMessageFromTarget)
if err := json.Unmarshal(msg.Params, event); err != nil {
b.errf("%s", err)
continue
}
sessionID = event.SessionID
msg = new(cdproto.Message)
if err := json.Unmarshal([]byte(event.Message), msg); err != nil {
b.errf("%s", err)
continue
}
}
switch {
case msg.Method != "":
if sessionID == "" {
// TODO: are we interested in browser events?
continue
}
tabEventQueue <- tabEvent{
sessionID: sessionID,
msg: msg,
}
case msg.ID != 0:
// We can't process the response here, as it's
// another goroutine that maintans respByID.
resQueue <- msg
default:
b.errf("ignoring malformed incoming message (missing id or method): %#v", msg)
}
}
}()
// This goroutine handles tabs, as well as routing events to each tab
// via the pages map.
go func() {
// This map is only safe for use within this goroutine, so don't
// declare it as a Browser field.
pages := make(map[target.SessionID]*Target, 1024)
for {
select {
case tab := <-b.tabQueue:
if _, ok := pages[tab.sessionID]; ok {
b.errf("executor for %q already exists", tab.sessionID)
}
t := &Target{
browser: b,
TargetID: tab.targetID,
SessionID: tab.sessionID,
eventQueue: make(chan *cdproto.Message, 1024),
waitQueue: make(chan func(cur *cdp.Frame) bool, 1024),
frames: make(map[cdp.FrameID]*cdp.Frame),
logf: b.logf,
errf: b.errf,
}
go t.run(ctx)
pages[tab.sessionID] = t
b.tabResult <- t
case event := <-tabEventQueue:
page, ok := pages[event.sessionID]
if !ok {
b.errf("unknown session ID %q", event.sessionID)
continue
}
select {
case page.eventQueue <- event.msg:
default:
panic("eventQueue is full")
}
case <-ctx.Done():
return
}
}
}()
respByID := make(map[int64]chan *cdproto.Message)
// This goroutine handles sending commands to the browser, and sending
// responses back for each of these commands via respByID.
for {
select {
case res := <-resQueue:
resp, ok := respByID[res.ID]
if !ok {
b.errf("id %d not present in response map", res.ID)
continue
}
if resp != nil {
// resp could be nil, if we're not interested in
// this response; for CommandSendMessageToTarget.
resp <- res
close(resp)
}
delete(respByID, res.ID)
case q := <-b.cmdQueue:
if _, ok := respByID[q.msg.ID]; ok {
b.errf("id %d already present in response map", q.msg.ID)
continue
}
respByID[q.msg.ID] = q.resp
if q.msg.Method == "" {
// Only register the chananel in respByID;
// useful for CommandSendMessageToTarget.
continue
}
if err := b.conn.Write(q.msg); err != nil {
b.errf("%s", err)
continue
}
case <-ctx.Done():
return
}
}
}
// BrowserOption is a browser option.
type BrowserOption func(*Browser)
// WithBrowserLogf is a browser option to specify a func to receive general logging.
func WithBrowserLogf(f func(string, ...interface{})) BrowserOption {
return func(b *Browser) { b.logf = f }
}
// WithBrowserErrorf is a browser option to specify a func to receive error logging.
func WithBrowserErrorf(f func(string, ...interface{})) BrowserOption {
return func(b *Browser) { b.errf = f }
}
// WithBrowserDebugf is a browser option to specify a func to log actual
// websocket messages.
func WithBrowserDebugf(f func(string, ...interface{})) BrowserOption {
return func(b *Browser) { b.dbgf = f }
}
// WithConsolef is a browser option to specify a func to receive chrome log events.
//
// Note: NOT YET IMPLEMENTED.
func WithConsolef(f func(string, ...interface{})) BrowserOption {
return func(b *Browser) {
}
}

View File

@ -1,433 +1,292 @@
// Package chromedp is a high level Chrome Debugging Protocol domain manager
// that simplifies driving web browsers (Chrome, Safari, Edge, Android Web
// Views, and others) for scraping, unit testing, or profiling web pages.
// Package chromedp is a high level Chrome DevTools Protocol client that
// simplifies driving browsers for scraping, unit testing, or profiling web
// pages using the CDP.
//
// chromedp requires no third-party dependencies (ie, Selenium), implementing
// the async Chrome Debugging Protocol natively.
// chromedp requires no third-party dependencies, implementing the async Chrome
// DevTools Protocol entirely in Go.
package chromedp
import (
"context"
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/chromedp/client"
"github.com/chromedp/chromedp/runner"
"github.com/chromedp/cdproto/css"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/inspector"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/target"
)
const (
// DefaultNewTargetTimeout is the default time to wait for a new target to
// be started.
DefaultNewTargetTimeout = 3 * time.Second
// Context is attached to any context.Context which is valid for use with Run.
type Context struct {
// Allocator is used to create new browsers. It is inherited from the
// parent context when using NewContext.
Allocator Allocator
// DefaultCheckDuration is the default time to sleep between a check.
DefaultCheckDuration = 50 * time.Millisecond
// Browser is the browser being used in the context. It is inherited
// from the parent context when using NewContext.
Browser *Browser
// DefaultPoolStartPort is the default start port number.
DefaultPoolStartPort = 9000
// Target is the target to run actions (commands) against. It is not
// inherited from the parent context, and typically each context will
// have its own unique Target pointing to a separate browser tab (page).
Target *Target
// DefaultPoolEndPort is the default end port number.
DefaultPoolEndPort = 10000
)
// browserOpts holds the browser options passed to NewContext via
// WithBrowserOption, so that they can later be used when allocating a
// browser in Run.
browserOpts []BrowserOption
// CDP contains information for managing a Chrome process runner, low level
// JSON and websocket client, and associated network, page, and DOM handling.
type CDP struct {
// r is the chrome runner.
r *runner.Runner
// cancel simply cancels the context that was used to start Browser.
// This is useful to stop all activity and avoid deadlocks if we detect
// that the browser was closed or happened to crash. Note that this
// cancel function doesn't do any waiting.
cancel func()
// opts are command line options to pass to a created runner.
opts []runner.CommandLineOption
// first records whether this context was the one that allocated
// Browser. This is important, because its cancellation will stop the
// entire browser handler, meaning that no further actions can be
// executed.
first bool
// watch is the channel for new client targets.
watch <-chan client.Target
// wg allows waiting for a target to be closed on cancellation.
wg sync.WaitGroup
// cur is the current active target's handler.
cur cdp.Executor
// handlers is the active handlers.
handlers []*TargetHandler
// handlerMap is the map of target IDs to its active handler.
handlerMap map[string]int
// logging funcs
logf, debugf, errf func(string, ...interface{})
sync.RWMutex
// cancelErr is the first error encountered when cancelling this
// context, for example if a browser's temporary user data directory
// couldn't be deleted.
cancelErr error
}
// New creates and starts a new CDP instance.
func New(ctxt context.Context, opts ...Option) (*CDP, error) {
c := &CDP{
handlers: make([]*TargetHandler, 0),
handlerMap: make(map[string]int),
logf: log.Printf,
debugf: func(string, ...interface{}) {},
errf: func(s string, v ...interface{}) { log.Printf("error: "+s, v...) },
// NewContext creates a chromedp context from the parent context. The parent
// context's Allocator is inherited, defaulting to an ExecAllocator with
// DefaultExecAllocatorOptions.
//
// If the parent context contains an allocated Browser, the child context
// inherits it, and its first Run creates a new tab on that browser. Otherwise,
// its first Run will allocate a new browser.
//
// Cancelling the returned context will close a tab or an entire browser,
// depending on the logic described above. To cancel a context while checking
// for errors, see Cancel.
func NewContext(parent context.Context, opts ...ContextOption) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
c := &Context{cancel: cancel, first: true}
if pc := FromContext(parent); pc != nil {
c.Allocator = pc.Allocator
c.Browser = pc.Browser
// don't inherit Target, so that NewContext can be used to
// create a new tab on the same browser.
c.first = c.Browser == nil
}
// apply options
for _, o := range opts {
if err := o(c); err != nil {
return nil, err
}
}
// check for supplied runner, if none then create one
if c.r == nil && c.watch == nil {
var err error
c.r, err = runner.Run(ctxt, c.opts...)
if err != nil {
return nil, err
}
}
// watch handlers
if c.watch == nil {
c.watch = c.r.WatchPageTargets(ctxt)
o(c)
}
if c.Allocator == nil {
c.Allocator = setupExecAllocator(DefaultExecAllocatorOptions...)
}
ctx = context.WithValue(ctx, contextKey{}, c)
c.wg.Add(1)
go func() {
for t := range c.watch {
if t == nil {
return
}
go c.AddTarget(ctxt, t)
<-ctx.Done()
if c.first {
// This is the original browser tab, so the entire
// browser will already be cleaned up elsewhere.
c.wg.Done()
return
}
if c.Target == nil {
// This is a new tab, but we didn't create it and attach
// to it yet. Nothing to do.
c.wg.Done()
return
}
// Not the original browser tab; simply detach and close it.
// We need a new context, as ctx is cancelled; use a 1s timeout.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if id := c.Target.SessionID; id != "" {
action := target.DetachFromTarget().WithSessionID(id)
if err := action.Do(ctx, c.Browser); c.cancelErr == nil {
c.cancelErr = err
}
}
if id := c.Target.TargetID; id != "" {
action := target.CloseTarget(id)
if ok, err := action.Do(ctx, c.Browser); c.cancelErr == nil {
if !ok && err == nil {
err = fmt.Errorf("could not close target %q", id)
}
c.cancelErr = err
}
}
c.wg.Done()
}()
// TODO: fix this
timeout := time.After(defaultNewTargetTimeout)
// wait until at least one target active
for {
select {
default:
c.RLock()
exists := c.cur != nil
c.RUnlock()
if exists {
return c, nil
}
// TODO: fix this
time.Sleep(DefaultCheckDuration)
case <-ctxt.Done():
return nil, ctxt.Err()
case <-timeout:
return nil, errors.New("timeout waiting for initial target")
}
cancelWait := func() {
cancel()
c.wg.Wait()
}
return ctx, cancelWait
}
// AddTarget adds a target using the supplied context.
func (c *CDP) AddTarget(ctxt context.Context, t client.Target) {
c.Lock()
defer c.Unlock()
type contextKey struct{}
// create target manager
h, err := NewTargetHandler(t, c.logf, c.debugf, c.errf)
if err != nil {
c.errf("could not create handler for %s: %v", t, err)
return
}
// run
if err := h.Run(ctxt); err != nil {
c.errf("could not start handler for %s: %v", t, err)
return
}
// add to active handlers
c.handlers = append(c.handlers, h)
c.handlerMap[t.GetID()] = len(c.handlers) - 1
if c.cur == nil {
c.cur = h
}
// FromContext extracts the Context data stored inside a context.Context.
func FromContext(ctx context.Context) *Context {
c, _ := ctx.Value(contextKey{}).(*Context)
return c
}
// Wait waits for the Chrome runner to terminate.
func (c *CDP) Wait() error {
c.RLock()
r := c.r
c.RUnlock()
if r != nil {
return r.Wait()
// Cancel cancels a chromedp context, waits for its resources to be cleaned up,
// and returns any error encountered during that process.
//
// Usually a "defer cancel()" will be enough for most use cases. This API is
// useful if you want to catch underlying cancel errors, such as when a
// temporary directory cannot be deleted.
func Cancel(ctx context.Context) error {
c := FromContext(ctx)
if c == nil {
return ErrInvalidContext
}
return nil
c.cancel()
c.wg.Wait()
return c.cancelErr
}
// Shutdown closes all Chrome page handlers.
func (c *CDP) Shutdown(ctxt context.Context, opts ...client.Option) error {
c.RLock()
defer c.RUnlock()
if c.r != nil {
return c.r.Shutdown(ctxt, opts...)
// Run runs an action against context. The provided context must be a valid
// chromedp context, typically created via NewContext.
func Run(ctx context.Context, actions ...Action) error {
c := FromContext(ctx)
// If c is nil, it's not a chromedp context.
// If c.Allocator is nil, NewContext wasn't used properly.
// If c.cancel is nil, Run is being called directly with an allocator
// context.
if c == nil || c.Allocator == nil || c.cancel == nil {
return ErrInvalidContext
}
return nil
}
// ListTargets returns the target IDs of the managed targets.
func (c *CDP) ListTargets() []string {
c.RLock()
defer c.RUnlock()
i, targets := 0, make([]string, len(c.handlers))
for k := range c.handlerMap {
targets[i] = k
i++
}
return targets
}
// GetHandlerByIndex retrieves the domains manager for the specified index.
func (c *CDP) GetHandlerByIndex(i int) cdp.Executor {
c.RLock()
defer c.RUnlock()
if i < 0 || i >= len(c.handlers) {
return nil
}
return c.handlers[i]
}
// GetHandlerByID retrieves the domains manager for the specified target ID.
func (c *CDP) GetHandlerByID(id string) cdp.Executor {
c.RLock()
defer c.RUnlock()
if i, ok := c.handlerMap[id]; ok {
return c.handlers[i]
}
return nil
}
// SetHandler sets the active handler to the target with the specified index.
func (c *CDP) SetHandler(i int) error {
c.Lock()
defer c.Unlock()
if i < 0 || i >= len(c.handlers) {
return fmt.Errorf("no handler associated with target index %d", i)
}
c.cur = c.handlers[i]
return nil
}
// SetHandlerByID sets the active target to the target with the specified id.
func (c *CDP) SetHandlerByID(id string) error {
c.Lock()
defer c.Unlock()
if i, ok := c.handlerMap[id]; ok {
c.cur = c.handlers[i]
}
return fmt.Errorf("no handler associated with target id %s", id)
}
// newTarget creates a new target using supplied context and options, returning
// the id of the created target only after the target has been started for
// monitoring.
func (c *CDP) newTarget(ctxt context.Context, opts ...client.Option) (string, error) {
c.RLock()
cl := c.r.Client(opts...)
c.RUnlock()
// new page target
t, err := cl.NewPageTarget(ctxt)
if err != nil {
return "", err
}
timeout := time.After(DefaultNewTargetTimeout)
for {
select {
default:
var ok bool
id := t.GetID()
c.RLock()
_, ok = c.handlerMap[id]
c.RUnlock()
if ok {
return id, nil
}
time.Sleep(DefaultCheckDuration)
case <-ctxt.Done():
return "", ctxt.Err()
case <-timeout:
return "", errors.New("timeout waiting for new target to be available")
}
}
}
// SetTarget is an action that sets the active Chrome handler to the specified
// index i.
func (c *CDP) SetTarget(i int) Action {
return ActionFunc(func(context.Context, cdp.Executor) error {
return c.SetHandler(i)
})
}
// SetTargetByID is an action that sets the active Chrome handler to the handler
// associated with the specified id.
func (c *CDP) SetTargetByID(id string) Action {
return ActionFunc(func(context.Context, cdp.Executor) error {
return c.SetHandlerByID(id)
})
}
// NewTarget is an action that creates a new Chrome target, and sets it as the
// active target.
func (c *CDP) NewTarget(id *string, opts ...client.Option) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
n, err := c.newTarget(ctxt, opts...)
if c.Browser == nil {
browser, err := c.Allocator.Allocate(ctx, c.browserOpts...)
if err != nil {
return err
}
if id != nil {
*id = n
c.Browser = browser
}
if c.Target == nil {
if err := c.newSession(ctx); err != nil {
return err
}
return nil
})
}
return Tasks(actions).Do(ctx, c.Target)
}
// CloseByIndex closes the Chrome target with specified index i.
func (c *CDP) CloseByIndex(i int) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return nil
})
func (c *Context) newSession(ctx context.Context) error {
var targetID target.ID
if c.first {
// If we just allocated this browser, and it has a single page
// that's blank and not attached, use it.
infos, err := target.GetTargets().Do(ctx, c.Browser)
if err != nil {
return err
}
pages := 0
for _, info := range infos {
if info.Type == "page" && info.URL == "about:blank" && !info.Attached {
targetID = info.TargetID
pages++
}
}
if pages > 1 {
// Multiple blank pages; just in case, don't use any.
targetID = ""
}
}
if targetID == "" {
var err error
targetID, err = target.CreateTarget("about:blank").Do(ctx, c.Browser)
if err != nil {
return err
}
}
sessionID, err := target.AttachToTarget(targetID).Do(ctx, c.Browser)
if err != nil {
return err
}
c.Target = c.Browser.newExecutorForTarget(ctx, targetID, sessionID)
// enable domains
for _, enable := range []Action{
log.Enable(),
runtime.Enable(),
// network.Enable(),
inspector.Enable(),
page.Enable(),
dom.Enable(),
css.Enable(),
} {
if err := enable.Do(ctx, c.Target); err != nil {
return fmt.Errorf("unable to execute %T: %v", enable, err)
}
}
return nil
}
// CloseByID closes the Chrome target with the specified id.
func (c *CDP) CloseByID(id string) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return nil
})
// ContextOption is a context option.
type ContextOption func(*Context)
// WithLogf is a shortcut for WithBrowserOption(WithBrowserLogf(f)).
func WithLogf(f func(string, ...interface{})) ContextOption {
return WithBrowserOption(WithBrowserLogf(f))
}
// Run executes the action against the current target using the supplied
// context.
func (c *CDP) Run(ctxt context.Context, a Action) error {
c.RLock()
cur := c.cur
c.RUnlock()
return a.Do(ctxt, cur)
// WithErrorf is a shortcut for WithBrowserOption(WithBrowserErrorf(f)).
func WithErrorf(f func(string, ...interface{})) ContextOption {
return WithBrowserOption(WithBrowserErrorf(f))
}
// Option is a Chrome Debugging Protocol option.
type Option func(*CDP) error
// WithDebugf is a shortcut for WithBrowserOption(WithBrowserDebugf(f)).
func WithDebugf(f func(string, ...interface{})) ContextOption {
return WithBrowserOption(WithBrowserDebugf(f))
}
// WithRunner is a CDP option to specify the underlying Chrome runner to
// monitor for page handlers.
func WithRunner(r *runner.Runner) Option {
return func(c *CDP) error {
c.r = r
return nil
// WithBrowserOption allows passing a number of browser options to the allocator
// when allocating a new browser. As such, this context option can only be used
// when NewContext is allocating a new browser.
func WithBrowserOption(opts ...BrowserOption) ContextOption {
return func(c *Context) {
if !c.first {
panic("WithBrowserOption can only be used when allocating a new browser")
}
c.browserOpts = append(c.browserOpts, opts...)
}
}
// WithTargets is a CDP option to specify the incoming targets to monitor for
// page handlers.
func WithTargets(watch <-chan client.Target) Option {
return func(c *CDP) error {
c.watch = watch
return nil
// Targets lists all the targets in the browser attached to the given context.
func Targets(ctx context.Context) ([]*target.Info, error) {
// Don't rely on Run, as that needs to be able to call Targets, and we
// don't want cyclic func calls.
c := FromContext(ctx)
if c == nil || c.Allocator == nil {
return nil, ErrInvalidContext
}
}
// WithClient is a CDP option to use the incoming targets from a client.
func WithClient(ctxt context.Context, cl *client.Client) Option {
return func(c *CDP) error {
return WithTargets(cl.WatchPageTargets(ctxt))(c)
if c.Browser == nil {
browser, err := c.Allocator.Allocate(ctx, c.browserOpts...)
if err != nil {
return nil, err
}
c.Browser = browser
}
return target.GetTargets().Do(ctx, c.Browser)
}
// WithURL is a CDP option to use a client with the specified URL.
func WithURL(ctxt context.Context, urlstr string) Option {
return func(c *CDP) error {
return WithClient(ctxt, client.New(client.URL(urlstr)))(c)
}
}
// WithRunnerOptions is a CDP option to specify the options to pass to a newly
// created Chrome process runner.
func WithRunnerOptions(opts ...runner.CommandLineOption) Option {
return func(c *CDP) error {
c.opts = opts
return nil
}
}
// WithLogf is a CDP option to specify a func to receive general logging.
func WithLogf(f func(string, ...interface{})) Option {
return func(c *CDP) error {
c.logf = f
return nil
}
}
// WithDebugf is a CDP option to specify a func to receive debug logging (ie,
// protocol information).
func WithDebugf(f func(string, ...interface{})) Option {
return func(c *CDP) error {
c.debugf = f
return nil
}
}
// WithErrorf is a CDP option to specify a func to receive error logging.
func WithErrorf(f func(string, ...interface{})) Option {
return func(c *CDP) error {
c.errf = f
return nil
}
}
// WithLog is a CDP option that sets the logging, debugging, and error funcs to
// f.
func WithLog(f func(string, ...interface{})) Option {
return func(c *CDP) error {
c.logf, c.debugf, c.errf = f, f, f
return nil
}
}
// WithConsolef is a CDP option to specify a func to receive chrome log events.
//
// Note: NOT YET IMPLEMENTED.
func WithConsolef(f func(string, ...interface{})) Option {
return func(c *CDP) error {
return nil
}
}
var (
// defaultNewTargetTimeout is the default target timeout -- used by
// testing.
defaultNewTargetTimeout = DefaultNewTargetTimeout
)

View File

@ -2,125 +2,226 @@ package chromedp
import (
"context"
"log"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path"
"runtime"
"testing"
"time"
"github.com/chromedp/chromedp/runner"
)
var (
pool *Pool
testdataDir string
defaultContext, defaultCancel = context.WithCancel(context.Background())
browserCtx context.Context
cliOpts = []runner.CommandLineOption{
runner.NoDefaultBrowserCheck,
runner.NoFirstRun,
}
// allocOpts is filled in TestMain
allocOpts []ExecAllocatorOption
)
func testAllocate(t *testing.T, path string) *Res {
c, err := pool.Allocate(defaultContext, cliOpts...)
if err != nil {
t.Fatalf("could not allocate from pool: %v", err)
}
err = WithLogf(t.Logf)(c.c)
if err != nil {
t.Fatalf("could not set logf: %v", err)
}
err = WithDebugf(t.Logf)(c.c)
if err != nil {
t.Fatalf("could not set debugf: %v", err)
}
err = WithErrorf(t.Errorf)(c.c)
if err != nil {
t.Fatalf("could not set errorf: %v", err)
}
h := c.c.GetHandlerByIndex(0)
th, ok := h.(*TargetHandler)
if !ok {
t.Fatalf("handler is invalid type")
}
th.logf, th.debugf = t.Logf, t.Logf
th.errf = func(s string, v ...interface{}) {
t.Logf("TARGET HANDLER ERROR: "+s, v...)
}
func testAllocate(t *testing.T, path string) (_ context.Context, cancel func()) {
// Same browser, new tab; not needing to start new chrome browsers for
// each test gives a huge speed-up.
ctx, _ := NewContext(browserCtx)
// Only navigate if we want a path, otherwise leave the blank page.
if path != "" {
err = c.Run(defaultContext, Navigate(testdataDir+"/"+path))
if err != nil {
t.Fatalf("could not navigate to testdata/%s: %v", path, err)
if err := Run(ctx, Navigate(testdataDir+"/"+path)); err != nil {
t.Fatal(err)
}
}
return c
cancelErr := func() {
if err := Cancel(ctx); err != nil {
t.Error(err)
}
}
return ctx, cancelErr
}
func TestMain(m *testing.M) {
var err error
wd, err := os.Getwd()
if err != nil {
log.Fatalf("could not get working directory: %v", err)
os.Exit(1)
panic(fmt.Sprintf("could not get working directory: %v", err))
}
testdataDir = "file://" + path.Join(wd, "testdata")
// its worth noting that newer versions of chrome (64+) run much faster
// than older ones -- same for headless_shell ...
execPath := runner.DefaultChromePath
if testRunner := os.Getenv("CHROMEDP_TEST_RUNNER"); testRunner != "" {
execPath = testRunner
} else {
// use headless_shell, if on path
var hsPath string
hsPath, err = exec.LookPath("headless_shell")
if err == nil {
execPath = hsPath
}
}
cliOpts = append(cliOpts, runner.ExecPath(execPath))
// build on top of the default options
allocOpts = append(allocOpts, DefaultExecAllocatorOptions...)
// disabling the GPU helps portability with some systems like Travis,
// and can slightly speed up the tests on other systems
allocOpts = append(allocOpts, DisableGPU)
// it's worth noting that newer versions of chrome (64+) run much faster
// than older ones -- same for headless_shell ...
if execPath := os.Getenv("CHROMEDP_TEST_RUNNER"); execPath != "" {
allocOpts = append(allocOpts, ExecPath(execPath))
}
// not explicitly needed to be set, as this vastly speeds up unit tests
if noSandbox := os.Getenv("CHROMEDP_NO_SANDBOX"); noSandbox != "false" {
cliOpts = append(cliOpts, runner.NoSandbox)
}
// must be explicitly set, as disabling gpu slows unit tests
if disableGPU := os.Getenv("CHROMEDP_DISABLE_GPU"); disableGPU != "" && disableGPU != "false" {
cliOpts = append(cliOpts, runner.DisableGPU)
allocOpts = append(allocOpts, NoSandbox)
}
if targetTimeout := os.Getenv("CHROMEDP_TARGET_TIMEOUT"); targetTimeout != "" {
defaultNewTargetTimeout, _ = time.ParseDuration(targetTimeout)
}
if defaultNewTargetTimeout == 0 {
defaultNewTargetTimeout = 30 * time.Second
}
allocCtx, cancel := NewExecAllocator(context.Background(), allocOpts...)
//pool, err = NewPool(PoolLog(log.Printf, log.Printf, log.Printf))
pool, err = NewPool()
if err != nil {
log.Fatal(err)
// start the browser
browserCtx, _ = NewContext(allocCtx)
if err := Run(browserCtx); err != nil {
panic(err)
}
code := m.Run()
defaultCancel()
err = pool.Shutdown()
if err != nil {
log.Fatal(err)
}
cancel()
os.Exit(code)
}
func TestTargets(t *testing.T) {
t.Parallel()
// Start one browser with one tab.
ctx1, cancel1 := NewContext(context.Background())
defer cancel1()
if err := Run(ctx1); err != nil {
t.Fatal(err)
}
wantTargets := func(ctx context.Context, want int) {
t.Helper()
infos, err := Targets(ctx)
if err != nil {
t.Fatal(err)
}
if got := len(infos); want != got {
t.Fatalf("want %d targets, got %d", want, got)
}
}
wantTargets(ctx1, 1)
// Start a second tab on the same browser.
ctx2, cancel2 := NewContext(ctx1)
defer cancel2()
if err := Run(ctx2); err != nil {
t.Fatal(err)
}
wantTargets(ctx2, 2)
// The first context should also see both targets.
wantTargets(ctx1, 2)
// Cancelling the second context should close the second tab alone.
cancel2()
wantTargets(ctx1, 1)
// We used to have a bug where Run would reset the first context as if
// it weren't the first, breaking its cancellation.
if err := Run(ctx1); err != nil {
t.Fatal(err)
}
}
func TestBrowserQuit(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("os.Interrupt isn't supported on Windows")
}
// Simulate a scenario where we navigate to a page that's slow to
// respond, and the browser is closed before we can finish the
// navigation.
serve := make(chan bool, 1)
close := make(chan bool, 1)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close <- true
<-serve
fmt.Fprintf(w, "response")
}))
defer s.Close()
ctx, cancel := NewContext(context.Background())
defer cancel()
if err := Run(ctx); err != nil {
t.Fatal(err)
}
go func() {
<-close
b := FromContext(ctx).Browser
if err := b.process.Signal(os.Interrupt); err != nil {
t.Error(err)
}
serve <- true
}()
// Run should error with something other than "deadline exceeded" in
// much less than 5s.
ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
switch err := Run(ctx2, Navigate(s.URL)); err {
case nil:
t.Fatal("did not expect a nil error")
case context.DeadlineExceeded:
t.Fatalf("did not expect a standard context error: %v", err)
}
}
func TestCancelError(t *testing.T) {
t.Parallel()
ctx1, cancel1 := NewContext(context.Background())
defer cancel1()
if err := Run(ctx1); err != nil {
t.Fatal(err)
}
// Open and close a target normally; no error.
ctx2, cancel2 := NewContext(ctx1)
defer cancel2()
if err := Run(ctx2); err != nil {
t.Fatal(err)
}
if err := Cancel(ctx2); err != nil {
t.Fatalf("expected a nil error, got %v", err)
}
// Make "cancel" close the wrong target; error.
ctx3, cancel3 := NewContext(ctx1)
defer cancel3()
if err := Run(ctx3); err != nil {
t.Fatal(err)
}
FromContext(ctx3).Target.TargetID = "wrong"
if err := Cancel(ctx3); err == nil {
t.Fatalf("expected a non-nil error, got %v", err)
}
}
func TestPrematureCancel(t *testing.T) {
t.Parallel()
// Cancel before the browser is allocated.
ctx, cancel := NewContext(context.Background())
cancel()
if err := Run(ctx); err != context.Canceled {
t.Fatalf("wanted canceled context error, got %v", err)
}
}
func TestPrematureCancelTab(t *testing.T) {
t.Parallel()
ctx1, cancel := NewContext(context.Background())
defer cancel()
if err := Run(ctx1); err != nil {
t.Fatal(err)
}
// Cancel after the browser is allocated, but before we've created a new
// tab.
ctx2, cancel := NewContext(ctx1)
cancel()
Run(ctx2)
}

View File

@ -1,40 +0,0 @@
package client
import "fmt"
//go:generate easyjson -omit_empty -output_filename easyjson.go chrome.go
// Chrome holds connection information for a Chrome, Edge, or Safari target.
//
//easyjson:json
type Chrome struct {
Description string `json:"description,omitempty"`
DevtoolsURL string `json:"devtoolsFrontendUrl,omitempty"`
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Type TargetType `json:"type,omitempty"`
URL string `json:"url,omitempty"`
WebsocketURL string `json:"webSocketDebuggerUrl,omitempty"`
FaviconURL string `json:"faviconURL,omitempty"`
}
// String satisfies the stringer interface.
func (c Chrome) String() string {
return fmt.Sprintf("%s (`%s`)", c.ID, c.Title)
}
// GetID returns the target ID.
func (c *Chrome) GetID() string {
return c.ID
}
// GetType returns the target type.
func (c *Chrome) GetType() TargetType {
return c.Type
}
// GetWebsocketURL provides the websocket URL for the target, satisfying the
// domains.Target interface.
func (c *Chrome) GetWebsocketURL() string {
return c.WebsocketURL
}

View File

@ -1,350 +0,0 @@
// Package client provides the low level Chrome Debugging Protocol JSON types
// and related funcs.
package client
//go:generate go run gen.go
import (
"context"
"encoding/json"
"io/ioutil"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/mailru/easyjson"
)
const (
// DefaultEndpoint is the default endpoint to connect to.
DefaultEndpoint = "http://localhost:9222/json"
// DefaultWatchInterval is the default check duration.
DefaultWatchInterval = 100 * time.Millisecond
// DefaultWatchTimeout is the default watch timeout.
DefaultWatchTimeout = 5 * time.Second
)
// Error is a client error.
type Error string
// Error satisfies the error interface.
func (err Error) Error() string {
return string(err)
}
const (
// ErrUnsupportedProtocolType is the unsupported protocol type error.
ErrUnsupportedProtocolType Error = "unsupported protocol type"
// ErrUnsupportedProtocolVersion is the unsupported protocol version error.
ErrUnsupportedProtocolVersion Error = "unsupported protocol version"
)
// Target is the common interface for a Chrome Debugging Protocol target.
type Target interface {
String() string
GetID() string
GetType() TargetType
GetWebsocketURL() string
}
// Client is a Chrome Debugging Protocol client.
type Client struct {
url string
check time.Duration
timeout time.Duration
ver, typ string
rw sync.RWMutex
}
// New creates a new Chrome Debugging Protocol client.
func New(opts ...Option) *Client {
c := &Client{
url: DefaultEndpoint,
check: DefaultWatchInterval,
timeout: DefaultWatchTimeout,
}
// apply opts
for _, o := range opts {
o(c)
}
return c
}
// doReq executes a request.
func (c *Client) doReq(ctxt context.Context, action string, v interface{}) error {
// create request
req, err := http.NewRequest("GET", c.url+"/"+action, nil)
if err != nil {
return err
}
req = req.WithContext(ctxt)
cl := &http.Client{}
// execute
res, err := cl.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if v != nil {
// load body
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return err
}
// unmarshal
if z, ok := v.(easyjson.Unmarshaler); ok {
return easyjson.Unmarshal(body, z)
}
return json.Unmarshal(body, v)
}
return nil
}
// ListTargets returns a list of all targets.
func (c *Client) ListTargets(ctxt context.Context) ([]Target, error) {
var err error
var l []json.RawMessage
err = c.doReq(ctxt, "list", &l)
if err != nil {
return nil, err
}
t := make([]Target, len(l))
for i, v := range l {
t[i], err = c.newTarget(ctxt, v)
if err != nil {
return nil, err
}
}
return t, nil
}
// ListTargetsWithType returns a list of Targets with the specified target
// type.
func (c *Client) ListTargetsWithType(ctxt context.Context, typ TargetType) ([]Target, error) {
var err error
targets, err := c.ListTargets(ctxt)
if err != nil {
return nil, err
}
var ret []Target
for _, t := range targets {
if t.GetType() == typ {
ret = append(ret, t)
}
}
return ret, nil
}
// ListPageTargets lists the available Page targets.
func (c *Client) ListPageTargets(ctxt context.Context) ([]Target, error) {
return c.ListTargetsWithType(ctxt, Page)
}
var browserRE = regexp.MustCompile(`(?i)^(chrome|chromium|microsoft edge|safari)`)
// loadProtocolInfo loads the protocol information from the remote URL.
func (c *Client) loadProtocolInfo(ctxt context.Context) (string, string, error) {
c.rw.Lock()
defer c.rw.Unlock()
if c.ver == "" || c.typ == "" {
v, err := c.VersionInfo(ctxt)
if err != nil {
return "", "", err
}
if m := browserRE.FindAllStringSubmatch(v["Browser"], -1); len(m) != 0 {
c.typ = strings.ToLower(m[0][0])
}
c.ver = v["Protocol-Version"]
}
return c.ver, c.typ, nil
}
// newTarget creates a new target.
func (c *Client) newTarget(ctxt context.Context, buf []byte) (Target, error) {
var err error
ver, typ, err := c.loadProtocolInfo(ctxt)
if err != nil {
return nil, err
}
if ver != "1.1" && ver != "1.2" && ver != "1.3" {
return nil, ErrUnsupportedProtocolVersion
}
switch typ {
case "chrome", "chromium", "microsoft edge", "safari", "":
x := new(Chrome)
if buf != nil {
err = easyjson.Unmarshal(buf, x)
if err != nil {
return nil, err
}
}
return x, nil
}
return nil, ErrUnsupportedProtocolType
}
// NewPageTargetWithURL creates a new page target with the specified url.
func (c *Client) NewPageTargetWithURL(ctxt context.Context, urlstr string) (Target, error) {
var err error
t, err := c.newTarget(ctxt, nil)
if err != nil {
return nil, err
}
u := "new"
if urlstr != "" {
u += "?" + urlstr
}
err = c.doReq(ctxt, u, t)
if err != nil {
return nil, err
}
return t, nil
}
// NewPageTarget creates a new page target.
func (c *Client) NewPageTarget(ctxt context.Context) (Target, error) {
return c.NewPageTargetWithURL(ctxt, "")
}
// ActivateTarget activates a target.
func (c *Client) ActivateTarget(ctxt context.Context, t Target) error {
return c.doReq(ctxt, "activate/"+t.GetID(), nil)
}
// CloseTarget activates a target.
func (c *Client) CloseTarget(ctxt context.Context, t Target) error {
return c.doReq(ctxt, "close/"+t.GetID(), nil)
}
// VersionInfo returns information about the remote debugging protocol.
func (c *Client) VersionInfo(ctxt context.Context) (map[string]string, error) {
var err error
v := map[string]string{}
err = c.doReq(ctxt, "version", &v)
if err != nil {
return nil, err
}
return v, nil
}
// WatchPageTargets watches for new page targets.
func (c *Client) WatchPageTargets(ctxt context.Context) <-chan Target {
if ctxt == nil {
ctxt = context.Background()
}
ch := make(chan Target)
go func() {
defer close(ch)
encountered := make(map[string]bool)
check := func() error {
targets, err := c.ListPageTargets(ctxt)
if err != nil {
return err
}
for _, t := range targets {
if !encountered[t.GetID()] {
ch <- t
}
encountered[t.GetID()] = true
}
return nil
}
var err error
lastGood := time.Now()
for {
err = check()
if err == nil {
lastGood = time.Now()
} else if time.Now().After(lastGood.Add(c.timeout)) {
return
}
select {
case <-time.After(c.check):
continue
case <-ctxt.Done():
return
}
}
}()
return ch
}
// Option is a Chrome Debugging Protocol client option.
type Option func(*Client)
// URL is a client option to specify the remote Chrome instance to connect to.
func URL(urlstr string) Option {
return func(c *Client) {
// since chrome 66+, dev tools requires the host name to be either an
// IP address, or "localhost"
if strings.HasPrefix(strings.ToLower(urlstr), "http://") {
host, port, path := urlstr[7:], "", ""
if i := strings.Index(host, "/"); i != -1 {
host, path = host[:i], host[i:]
}
if i := strings.Index(host, ":"); i != -1 {
host, port = host[:i], host[i:]
}
if addr, err := net.ResolveIPAddr("ip", host); err == nil {
urlstr = "http://" + addr.IP.String() + port + path
}
}
c.url = urlstr
}
}
// WatchInterval is a client option that specifies the check interval duration.
func WatchInterval(check time.Duration) Option {
return func(c *Client) {
c.check = check
}
}
// WatchTimeout is a client option that specifies the watch timeout duration.
func WatchTimeout(timeout time.Duration) Option {
return func(c *Client) {
c.timeout = timeout
}
}

View File

@ -1,174 +0,0 @@
// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
package client
import (
json "encoding/json"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
jwriter "github.com/mailru/easyjson/jwriter"
)
// suppress unused package warning
var (
_ *json.RawMessage
_ *jlexer.Lexer
_ *jwriter.Writer
_ easyjson.Marshaler
)
func easyjsonC5a4559bDecodeGithubComChromedpChromedpClient(in *jlexer.Lexer, out *Chrome) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeString()
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "description":
out.Description = string(in.String())
case "devtoolsFrontendUrl":
out.DevtoolsURL = string(in.String())
case "id":
out.ID = string(in.String())
case "title":
out.Title = string(in.String())
case "type":
(out.Type).UnmarshalEasyJSON(in)
case "url":
out.URL = string(in.String())
case "webSocketDebuggerUrl":
out.WebsocketURL = string(in.String())
case "faviconURL":
out.FaviconURL = string(in.String())
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonC5a4559bEncodeGithubComChromedpChromedpClient(out *jwriter.Writer, in Chrome) {
out.RawByte('{')
first := true
_ = first
if in.Description != "" {
const prefix string = ",\"description\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.Description))
}
if in.DevtoolsURL != "" {
const prefix string = ",\"devtoolsFrontendUrl\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.DevtoolsURL))
}
if in.ID != "" {
const prefix string = ",\"id\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.ID))
}
if in.Title != "" {
const prefix string = ",\"title\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.Title))
}
if in.Type != "" {
const prefix string = ",\"type\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
(in.Type).MarshalEasyJSON(out)
}
if in.URL != "" {
const prefix string = ",\"url\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.URL))
}
if in.WebsocketURL != "" {
const prefix string = ",\"webSocketDebuggerUrl\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.WebsocketURL))
}
if in.FaviconURL != "" {
const prefix string = ",\"faviconURL\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.String(string(in.FaviconURL))
}
out.RawByte('}')
}
// MarshalJSON supports json.Marshaler interface
func (v Chrome) MarshalJSON() ([]byte, error) {
w := jwriter.Writer{}
easyjsonC5a4559bEncodeGithubComChromedpChromedpClient(&w, v)
return w.Buffer.BuildBytes(), w.Error
}
// MarshalEasyJSON supports easyjson.Marshaler interface
func (v Chrome) MarshalEasyJSON(w *jwriter.Writer) {
easyjsonC5a4559bEncodeGithubComChromedpChromedpClient(w, v)
}
// UnmarshalJSON supports json.Unmarshaler interface
func (v *Chrome) UnmarshalJSON(data []byte) error {
r := jlexer.Lexer{Data: data}
easyjsonC5a4559bDecodeGithubComChromedpChromedpClient(&r, v)
return r.Error()
}
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
func (v *Chrome) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjsonC5a4559bDecodeGithubComChromedpChromedpClient(l, v)
}

View File

@ -1,142 +0,0 @@
// +build ignore
package main
import (
"encoding/base64"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"os/exec"
"regexp"
"sort"
"github.com/knq/snaker"
)
const (
// chromiumSrc is the base chromium source repo location
chromiumSrc = "https://chromium.googlesource.com/chromium/src"
// devtoolsHTTPClientCc contains the target_type names.
devtoolsHTTPClientCc = chromiumSrc + "/+/master/chrome/test/chromedriver/chrome/devtools_http_client.cc?format=TEXT"
)
var (
flagOut = flag.String("out", "targettype.go", "out file")
typeAsStringRE = regexp.MustCompile(`type_as_string\s+==\s+"([^"]+)"`)
)
func main() {
flag.Parse()
// grab source
buf, err := grab(devtoolsHTTPClientCc)
if err != nil {
log.Fatal(err)
}
// find names
matches := typeAsStringRE.FindAllStringSubmatch(string(buf), -1)
names := make([]string, len(matches))
for i, m := range matches {
names[i] = m[1]
}
sort.Strings(names)
// process names
var constVals, decodeVals string
for _, n := range names {
name := snaker.SnakeToCamelIdentifier(n)
constVals += fmt.Sprintf("%s TargetType = \"%s\"\n", name, n)
decodeVals += fmt.Sprintf("case %s:\n*tt=%s\n", name, name)
}
err = ioutil.WriteFile(*flagOut, []byte(fmt.Sprintf(targetTypeSrc, constVals, decodeVals)), 0644)
if err != nil {
log.Fatal(err)
}
err = exec.Command("gofmt", "-w", "-s", *flagOut).Run()
if err != nil {
log.Fatal(err)
}
}
// grab retrieves a file from the chromium source code.
func grab(path string) ([]byte, error) {
res, err := http.Get(path)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
buf, err := base64.StdEncoding.DecodeString(string(body))
if err != nil {
return nil, err
}
return buf, nil
}
const (
targetTypeSrc = `package client
// Code generated by gen.go. DO NOT EDIT.
import (
// "errors"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
jwriter "github.com/mailru/easyjson/jwriter"
)
// TargetType are the types of targets available in Chrome.
type TargetType string
// TargetType values.
const (
%s
)
// String satisfies stringer.
func (tt TargetType) String() string {
return string(tt)
}
// MarshalEasyJSON satisfies easyjson.Marshaler.
func (tt TargetType) MarshalEasyJSON(out *jwriter.Writer) {
out.String(string(tt))
}
// MarshalJSON satisfies json.Marshaler.
func (tt TargetType) MarshalJSON() ([]byte, error) {
return easyjson.Marshal(tt)
}
// UnmarshalEasyJSON satisfies easyjson.Unmarshaler.
func (tt *TargetType) UnmarshalEasyJSON(in *jlexer.Lexer) {
z := TargetType(in.String())
switch z {
%s
default:
// in.AddError(errors.New("unknown TargetType"))
*tt = z
}
}
// UnmarshalJSON satisfies json.Unmarshaler.
func (tt *TargetType) UnmarshalJSON(buf []byte) error {
return easyjson.Unmarshal(buf, tt)
}
`
)

View File

@ -1,81 +0,0 @@
package client
// Code generated by gen.go. DO NOT EDIT.
import (
// "errors"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
jwriter "github.com/mailru/easyjson/jwriter"
)
// TargetType are the types of targets available in Chrome.
type TargetType string
// TargetType values.
const (
App TargetType = "app"
BackgroundPage TargetType = "background_page"
Browser TargetType = "browser"
External TargetType = "external"
Iframe TargetType = "iframe"
Other TargetType = "other"
Page TargetType = "page"
ServiceWorker TargetType = "service_worker"
SharedWorker TargetType = "shared_worker"
Webview TargetType = "webview"
Worker TargetType = "worker"
)
// String satisfies stringer.
func (tt TargetType) String() string {
return string(tt)
}
// MarshalEasyJSON satisfies easyjson.Marshaler.
func (tt TargetType) MarshalEasyJSON(out *jwriter.Writer) {
out.String(string(tt))
}
// MarshalJSON satisfies json.Marshaler.
func (tt TargetType) MarshalJSON() ([]byte, error) {
return easyjson.Marshal(tt)
}
// UnmarshalEasyJSON satisfies easyjson.Unmarshaler.
func (tt *TargetType) UnmarshalEasyJSON(in *jlexer.Lexer) {
z := TargetType(in.String())
switch z {
case App:
*tt = App
case BackgroundPage:
*tt = BackgroundPage
case Browser:
*tt = Browser
case External:
*tt = External
case Iframe:
*tt = Iframe
case Other:
*tt = Other
case Page:
*tt = Page
case ServiceWorker:
*tt = ServiceWorker
case SharedWorker:
*tt = SharedWorker
case Webview:
*tt = Webview
case Worker:
*tt = Worker
default:
// in.AddError(errors.New("unknown TargetType"))
*tt = z
}
}
// UnmarshalJSON satisfies json.Unmarshaler.
func (tt *TargetType) UnmarshalJSON(buf []byte) error {
return easyjson.Unmarshal(buf, tt)
}

View File

@ -1,69 +0,0 @@
package client
import (
"io"
"github.com/gorilla/websocket"
)
const (
// DefaultReadBufferSize is the default maximum read buffer size.
DefaultReadBufferSize = 25 * 1024 * 1024
// DefaultWriteBufferSize is the default maximum write buffer size.
DefaultWriteBufferSize = 10 * 1024 * 1024
)
// Transport is the common interface to send/receive messages.
type Transport interface {
Read() ([]byte, error)
Write([]byte) error
io.Closer
}
// Conn wraps a gorilla/websocket.Conn connection.
type Conn struct {
*websocket.Conn
}
// Read reads the next websocket message.
func (c *Conn) Read() ([]byte, error) {
_, buf, err := c.ReadMessage()
if err != nil {
return nil, err
}
return buf, nil
}
// Write writes a websocket message.
func (c *Conn) Write(buf []byte) error {
return c.WriteMessage(websocket.TextMessage, buf)
}
// Dial dials the specified target's websocket URL.
//
// Note: uses gorilla/websocket.
func Dial(t Target, opts ...DialOption) (Transport, error) {
d := &websocket.Dialer{
ReadBufferSize: DefaultReadBufferSize,
WriteBufferSize: DefaultWriteBufferSize,
}
// apply opts
for _, o := range opts {
o(d)
}
// connect
conn, _, err := d.Dial(t.GetWebsocketURL(), nil)
if err != nil {
return nil, err
}
return &Conn{conn}, nil
}
// DialOption is a dial option.
type DialOption func(*websocket.Dialer)
// TODO: add dial options ...

152
conn.go Normal file
View File

@ -0,0 +1,152 @@
package chromedp
import (
"context"
"io"
"io/ioutil"
"net"
"strings"
"github.com/chromedp/cdproto"
"github.com/gorilla/websocket"
"github.com/mailru/easyjson"
)
var (
// DefaultReadBufferSize is the default maximum read buffer size.
DefaultReadBufferSize = 25 * 1024 * 1024
// DefaultWriteBufferSize is the default maximum write buffer size.
DefaultWriteBufferSize = 10 * 1024 * 1024
)
// Transport is the common interface to send/receive messages to a target.
type Transport interface {
Read() (*cdproto.Message, error)
Write(*cdproto.Message) error
io.Closer
}
// Conn wraps a gorilla/websocket.Conn connection.
type Conn struct {
*websocket.Conn
dbgf func(string, ...interface{})
}
// DialContext dials the specified websocket URL using gorilla/websocket.
func DialContext(ctx context.Context, urlstr string, opts ...DialOption) (*Conn, error) {
d := &websocket.Dialer{
ReadBufferSize: DefaultReadBufferSize,
WriteBufferSize: DefaultWriteBufferSize,
}
// connect
conn, _, err := d.DialContext(ctx, urlstr, nil)
if err != nil {
return nil, err
}
// apply opts
c := &Conn{
Conn: conn,
}
for _, o := range opts {
o(c)
}
return c, nil
}
// Read reads the next message.
func (c *Conn) Read() (*cdproto.Message, error) {
// get websocket reader
typ, r, err := c.NextReader()
if err != nil {
return nil, err
}
if typ != websocket.TextMessage {
return nil, ErrInvalidWebsocketMessage
}
// when dbgf defined, buffer, log, unmarshal
if c.dbgf != nil {
// buffer output
buf, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
c.dbgf("<- %s", string(buf))
msg := new(cdproto.Message)
if err = easyjson.Unmarshal(buf, msg); err != nil {
return nil, err
}
return msg, nil
}
// unmarshal direct from reader
msg := new(cdproto.Message)
if err = easyjson.UnmarshalFromReader(r, msg); err != nil {
return nil, err
}
return msg, nil
}
// Write writes a message.
func (c *Conn) Write(msg *cdproto.Message) error {
w, err := c.NextWriter(websocket.TextMessage)
if err != nil {
return err
}
if c.dbgf != nil {
var buf []byte
buf, err = easyjson.Marshal(msg)
if err != nil {
return err
}
c.dbgf("-> %s", string(buf))
_, err = w.Write(buf)
if err != nil {
return err
}
} else {
// direct marshal
_, err = easyjson.MarshalToWriter(msg, w)
if err != nil {
return err
}
}
return w.Close()
}
// ForceIP forces the host component in urlstr to be an IP address.
//
// Since Chrome 66+, Chrome DevTools Protocol clients connecting to a browser
// must send the "Host:" header as either an IP address, or "localhost".
func ForceIP(urlstr string) string {
if i := strings.Index(urlstr, "://"); i != -1 {
scheme := urlstr[:i+3]
host, port, path := urlstr[len(scheme)+3:], "", ""
if i := strings.Index(host, "/"); i != -1 {
host, path = host[:i], host[i:]
}
if i := strings.Index(host, ":"); i != -1 {
host, port = host[:i], host[i:]
}
if addr, err := net.ResolveIPAddr("ip", host); err == nil {
urlstr = scheme + addr.IP.String() + port + path
}
}
return urlstr
}
// DialOption is a dial option.
type DialOption func(*Conn)
// WithConnDebugf is a dial option to set a protocol logger.
func WithConnDebugf(f func(string, ...interface{})) DialOption {
return func(c *Conn) {
c.dbgf = f
}
}

View File

@ -1,34 +0,0 @@
#!/bin/bash
SRC=$(realpath $(cd -P "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/../)
pushd $SRC &> /dev/null
gometalinter \
--disable=aligncheck \
--enable=misspell \
--enable=gofmt \
--deadline=100s \
--cyclo-over=25 \
--sort=path \
--exclude='\(defer (.+?)\)\) \(errcheck\)$' \
--exclude='/easyjson\.go.*(passes|copies) lock' \
--exclude='/easyjson\.go.*ineffectual assignment' \
--exclude='/easyjson\.go.*unnecessary conversion' \
--exclude='/easyjson\.go.*this value of key is never used' \
--exclude='/easyjson\.go.*\((gocyclo|golint|goconst|staticcheck)\)$' \
--exclude='^cdp/.*Potential hardcoded credentials' \
--exclude='^cdp/cdp\.go.*UnmarshalEasyJSON.*\(gocyclo\)$' \
--exclude='^cdp/cdputil/cdputil\.go.*UnmarshalMessage.*\(gocyclo\)$' \
--exclude='^cmd/chromedp-gen/.*\((gocyclo|interfacer)\)$' \
--exclude='^cmd/chromedp-proxy/main\.go.*\(gas\)$' \
--exclude='^cmd/chromedp-gen/fixup/fixup\.go.*\(goconst\)$' \
--exclude='^cmd/chromedp-gen/internal/enum\.go.*unreachable' \
--exclude='^cmd/chromedp-gen/(main|domain-gen)\.go.*\(gas\)$' \
--exclude='^examples/[a-z]+/main\.go.*\(errcheck\)$' \
--exclude='^kb/gen\.go.*\((gas|vet)\)$' \
--exclude='^runner/.*\(gas\)$' \
--exclude='^handler\.go.*cmd can be easyjson\.Marshaler' \
./...
popd &> /dev/null

View File

@ -1,10 +0,0 @@
#!/bin/bash
TMP=$(mktemp -d /tmp/google-chrome.XXXXX)
google-chrome \
--user-data-dir=$TMP \
--remote-debugging-port=9222 \
--no-first-run \
--no-default-browser-check \
about:blank

View File

@ -1,14 +0,0 @@
#!/bin/bash
BASE=$(realpath $(cd -P $GOPATH/src/github.com/chromedp && pwd))
FILES=$(find $BASE/{chromedp*,goquery,examples} -type f -iname \*.go -not -iname \*.qtpl.go -print0|wc -l --files0-from=-|head -n -1)$'\n'
AUTOG=$(find $BASE/cdproto/ -type f -iname \*.go -not -iname \*easyjson\* -print0|wc -l --files0-from=-|head -n -1)
if [ "$1" != "--total" ]; then
echo -e "code:\n$FILES\n\ngenerated:\n$AUTOG"
else
echo "code: $(awk '{s+=$1} END {print s}' <<< "$FILES")"
echo "generated: $(awk '{s+=$1} END {print s}' <<< "$AUTOG")"
fi

View File

@ -10,6 +10,9 @@ func (err Error) Error() string {
// Error types.
const (
// ErrInvalidWebsocketMessage is the invalid websocket message.
ErrInvalidWebsocketMessage Error = "invalid websocket message"
// ErrInvalidDimensions is the invalid dimensions error.
ErrInvalidDimensions Error = "invalid dimensions"
@ -39,4 +42,7 @@ const (
// ErrInvalidHandler is the invalid handler error.
ErrInvalidHandler Error = "invalid handler"
// ErrInvalidContext is the invalid context error.
ErrInvalidContext Error = "invalid context"
)

View File

@ -11,7 +11,7 @@ import (
// Evaluate is an action to evaluate the Javascript expression, unmarshaling
// the result of the script evaluation to res.
//
// When res is a type other than *[]byte, or **chromedp/cdp/runtime.RemoteObject,
// When res is a type other than *[]byte, or **chromedp/cdproto/runtime.RemoteObject,
// then the result of the script evaluation will be returned "by value" (ie,
// JSON-encoded), and subsequently an attempt will be made to json.Unmarshal
// the script result to res.
@ -27,7 +27,7 @@ func Evaluate(expression string, res interface{}, opts ...EvaluateOption) Action
panic("res cannot be nil")
}
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
// set up parameters
p := runtime.Evaluate(expression)
switch res.(type) {
@ -42,7 +42,7 @@ func Evaluate(expression string, res interface{}, opts ...EvaluateOption) Action
}
// evaluate
v, exp, err := p.Do(ctxt, h)
v, exp, err := p.Do(ctx, h)
if err != nil {
return err
}

100
example_test.go Normal file
View File

@ -0,0 +1,100 @@
package chromedp_test
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"git.loafle.net/commons_go/chromedp"
)
func ExampleTitle() {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var title string
if err := chromedp.Run(ctx,
chromedp.Navigate("https://git.loafle.net/commons_go/chromedp/issues"),
chromedp.WaitVisible("#start-of-content", chromedp.ByID),
chromedp.Title(&title),
); err != nil {
panic(err)
}
fmt.Println(title)
// no expected output, to not run this test as part of 'go test'; it's
// too slow, requiring internet access.
}
func ExampleExecAllocator() {
dir, err := ioutil.TempDir("", "chromedp-example")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
chromedp.Headless,
chromedp.DisableGPU,
chromedp.UserDataDir(dir),
}
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
// also set up a custom logger
taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf))
defer cancel()
// ensure that the browser process is started
if err := chromedp.Run(taskCtx); err != nil {
panic(err)
}
path := filepath.Join(dir, "DevToolsActivePort")
bs, err := ioutil.ReadFile(path)
if err != nil {
panic(err)
}
lines := bytes.Split(bs, []byte("\n"))
fmt.Printf("DevToolsActivePort has %d lines\n", len(lines))
// Output:
// DevToolsActivePort has 2 lines
}
func ExampleNewContext_manyTabs() {
// new browser, first tab
ctx1, cancel := chromedp.NewContext(context.Background())
defer cancel()
// ensure the first tab is created
if err := chromedp.Run(ctx1); err != nil {
panic(err)
}
// same browser, second tab
ctx2, _ := chromedp.NewContext(ctx1)
// ensure the second tab is created
if err := chromedp.Run(ctx2); err != nil {
panic(err)
}
c1 := chromedp.FromContext(ctx1)
c2 := chromedp.FromContext(ctx2)
fmt.Printf("Same browser: %t\n", c1.Browser == c2.Browser)
fmt.Printf("Same tab: %t\n", c1.Target == c2.Target)
// Output:
// Same browser: true
// Same tab: false
}

14
go.mod
View File

@ -1,10 +1,10 @@
module github.com/chromedp/chromedp
module git.loafle.net/commons_go/chromedp
go 1.12
require (
github.com/chromedp/cdproto v0.0.0-20180703215205-c125a34ea3b3
github.com/disintegration/imaging v1.4.2
github.com/gorilla/websocket v1.2.0
github.com/knq/sysutil v0.0.0-20180306023629-0218e141a794
github.com/mailru/easyjson v0.0.0-20180606163543-3fdea8d05856
golang.org/x/image v0.0.0-20180628062038-cc896f830ced
github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a
github.com/disintegration/imaging v1.6.0
github.com/gorilla/websocket v1.4.0
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983
)

22
go.sum
View File

@ -1,9 +1,13 @@
github.com/chromedp/cdproto v0.0.0-20180522032958-55db67b53f25/go.mod h1:C2GPAraqdt1KfZU7aSmx1XUgarNq/3JmxevQkmCjOVs=
github.com/chromedp/cdproto v0.0.0-20180703215205-c125a34ea3b3/go.mod h1:C2GPAraqdt1KfZU7aSmx1XUgarNq/3JmxevQkmCjOVs=
github.com/disintegration/imaging v1.4.2/go.mod h1:9B/deIUIrliYkyMTuXJd6OUFLcrZ2tf+3Qlwnaf/CjU=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/knq/sysutil v0.0.0-20180306023629-0218e141a794/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
github.com/mailru/easyjson v0.0.0-20180323154445-8b799c424f57/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20180606163543-3fdea8d05856/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
golang.org/x/image v0.0.0-20180403161127-f315e4403028/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20180628062038-cc896f830ced/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a h1:GZPhzysmNSpFnYVSzixFV/ECNILkkn5HJon7AOUNizg=
github.com/chromedp/cdproto v0.0.0-20190412020601-c4267f5c421a/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI=
github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA=
github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls=
github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983 h1:wL11wNW7dhKIcRCHSm4sHKPWz0tt4mwBsVodG7+Xyqg=
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=

View File

@ -1,657 +0,0 @@
package chromedp
import (
"context"
"encoding/json"
"fmt"
"reflect"
goruntime "runtime"
"strings"
"sync"
"time"
"github.com/mailru/easyjson"
"github.com/chromedp/cdproto"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/css"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/inspector"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp/client"
)
// TargetHandler manages a Chrome Debugging Protocol target.
type TargetHandler struct {
conn client.Transport
// frames is the set of encountered frames.
frames map[cdp.FrameID]*cdp.Frame
// cur is the current top level frame.
cur *cdp.Frame
// qcmd is the outgoing message queue.
qcmd chan *cdproto.Message
// qres is the incoming command result queue.
qres chan *cdproto.Message
// qevents is the incoming event queue.
qevents chan *cdproto.Message
// detached is closed when the detached event is received.
detached chan *inspector.EventDetached
pageWaitGroup, domWaitGroup *sync.WaitGroup
// last is the last sent message identifier.
last int64
lastm sync.Mutex
// res is the id->result channel map.
res map[int64]chan *cdproto.Message
resrw sync.RWMutex
// logging funcs
logf, debugf, errf func(string, ...interface{})
sync.RWMutex
}
// NewTargetHandler creates a new handler for the specified client target.
func NewTargetHandler(t client.Target, logf, debugf, errf func(string, ...interface{})) (*TargetHandler, error) {
conn, err := client.Dial(t)
if err != nil {
return nil, err
}
return &TargetHandler{
conn: conn,
logf: logf,
debugf: debugf,
errf: errf,
}, nil
}
// Run starts the processing of commands and events of the client target
// provided to NewTargetHandler.
//
// Callers can stop Run by closing the passed context.
func (h *TargetHandler) Run(ctxt context.Context) error {
// reset
h.Lock()
h.frames = make(map[cdp.FrameID]*cdp.Frame)
h.qcmd = make(chan *cdproto.Message)
h.qres = make(chan *cdproto.Message)
h.qevents = make(chan *cdproto.Message)
h.res = make(map[int64]chan *cdproto.Message)
h.detached = make(chan *inspector.EventDetached)
h.pageWaitGroup = new(sync.WaitGroup)
h.domWaitGroup = new(sync.WaitGroup)
h.Unlock()
// run
go h.run(ctxt)
// enable domains
for _, a := range []Action{
log.Enable(),
runtime.Enable(),
//network.Enable(),
inspector.Enable(),
page.Enable(),
dom.Enable(),
css.Enable(),
} {
if err := a.Do(ctxt, h); err != nil {
return fmt.Errorf("unable to execute %s: %v", reflect.TypeOf(a), err)
}
}
h.Lock()
// get page resources
tree, err := page.GetResourceTree().Do(ctxt, h)
if err != nil {
return fmt.Errorf("unable to get resource tree: %v", err)
}
h.frames[tree.Frame.ID] = tree.Frame
h.cur = tree.Frame
for _, c := range tree.ChildFrames {
h.frames[c.Frame.ID] = c.Frame
}
h.Unlock()
h.documentUpdated(ctxt)
return nil
}
// run handles the actual message processing to / from the web socket connection.
func (h *TargetHandler) run(ctxt context.Context) {
defer h.conn.Close()
// add cancel to context
ctxt, cancel := context.WithCancel(ctxt)
defer cancel()
go func() {
defer cancel()
for {
select {
default:
msg, err := h.read()
if err != nil {
return
}
switch {
case msg.Method != "":
h.qevents <- msg
case msg.ID != 0:
h.qres <- msg
default:
h.errf("ignoring malformed incoming message (missing id or method): %#v", msg)
}
case <-h.detached:
// FIXME: should log when detached, and reason
return
case <-ctxt.Done():
return
}
}
}()
// process queues
for {
select {
case ev := <-h.qevents:
err := h.processEvent(ctxt, ev)
if err != nil {
h.errf("could not process event %s: %v", ev.Method, err)
}
case res := <-h.qres:
err := h.processResult(res)
if err != nil {
h.errf("could not process result for message %d: %v", res.ID, err)
}
case cmd := <-h.qcmd:
err := h.processCommand(cmd)
if err != nil {
h.errf("could not process command message %d: %v", cmd.ID, err)
}
case <-ctxt.Done():
return
}
}
}
// read reads a message from the client connection.
func (h *TargetHandler) read() (*cdproto.Message, error) {
// read
buf, err := h.conn.Read()
if err != nil {
return nil, err
}
h.debugf("-> %s", string(buf))
// unmarshal
msg := new(cdproto.Message)
err = json.Unmarshal(buf, msg)
if err != nil {
return nil, err
}
return msg, nil
}
// processEvent processes an incoming event.
func (h *TargetHandler) processEvent(ctxt context.Context, msg *cdproto.Message) error {
if msg == nil {
return ErrChannelClosed
}
// unmarshal
ev, err := cdproto.UnmarshalMessage(msg)
if err != nil {
return err
}
switch e := ev.(type) {
case *inspector.EventDetached:
h.Lock()
defer h.Unlock()
h.detached <- e
return nil
case *dom.EventDocumentUpdated:
h.domWaitGroup.Wait()
go h.documentUpdated(ctxt)
return nil
}
d := msg.Method.Domain()
if d != "Page" && d != "DOM" {
return nil
}
switch d {
case "Page":
h.pageWaitGroup.Add(1)
go h.pageEvent(ctxt, ev)
case "DOM":
h.domWaitGroup.Add(1)
go h.domEvent(ctxt, ev)
}
return nil
}
// documentUpdated handles the document updated event, retrieving the document
// root for the root frame.
func (h *TargetHandler) documentUpdated(ctxt context.Context) {
f, err := h.WaitFrame(ctxt, cdp.EmptyFrameID)
if err != nil {
h.errf("could not get current frame: %v", err)
return
}
f.Lock()
defer f.Unlock()
// invalidate nodes
if f.Root != nil {
close(f.Root.Invalidated)
}
f.Nodes = make(map[cdp.NodeID]*cdp.Node)
f.Root, err = dom.GetDocument().WithPierce(true).Do(ctxt, h)
if err != nil {
h.errf("could not retrieve document root for %s: %v", f.ID, err)
return
}
f.Root.Invalidated = make(chan struct{})
walk(f.Nodes, f.Root)
}
// processResult processes an incoming command result.
func (h *TargetHandler) processResult(msg *cdproto.Message) error {
h.resrw.RLock()
defer h.resrw.RUnlock()
ch, ok := h.res[msg.ID]
if !ok {
return fmt.Errorf("id %d not present in res map", msg.ID)
}
defer close(ch)
ch <- msg
return nil
}
// processCommand writes a command to the client connection.
func (h *TargetHandler) processCommand(cmd *cdproto.Message) error {
// marshal
buf, err := json.Marshal(cmd)
if err != nil {
return err
}
h.debugf("<- %s", string(buf))
return h.conn.Write(buf)
}
// emptyObj is an empty JSON object message.
var emptyObj = easyjson.RawMessage([]byte(`{}`))
// Execute executes commandType against the endpoint passed to Run, using the
// provided context and params, decoding the result of the command to res.
func (h *TargetHandler) Execute(ctxt context.Context, methodType string, params json.Marshaler, res json.Unmarshaler) error {
var paramsBuf easyjson.RawMessage
if params == nil {
paramsBuf = emptyObj
} else {
var err error
paramsBuf, err = json.Marshal(params)
if err != nil {
return err
}
}
id := h.next()
// save channel
ch := make(chan *cdproto.Message, 1)
h.resrw.Lock()
h.res[id] = ch
h.resrw.Unlock()
// queue message
h.qcmd <- &cdproto.Message{
ID: id,
Method: cdproto.MethodType(methodType),
Params: paramsBuf,
}
errch := make(chan error, 1)
go func() {
defer close(errch)
select {
case msg := <-ch:
switch {
case msg == nil:
errch <- ErrChannelClosed
case msg.Error != nil:
errch <- msg.Error
case res != nil:
errch <- json.Unmarshal(msg.Result, res)
}
case <-ctxt.Done():
errch <- ctxt.Err()
}
h.resrw.Lock()
defer h.resrw.Unlock()
delete(h.res, id)
}()
return <-errch
}
// next returns the next message id.
func (h *TargetHandler) next() int64 {
h.lastm.Lock()
defer h.lastm.Unlock()
h.last++
return h.last
}
// GetRoot returns the current top level frame's root document node.
func (h *TargetHandler) GetRoot(ctxt context.Context) (*cdp.Node, error) {
var root *cdp.Node
for {
var cur *cdp.Frame
select {
default:
h.RLock()
cur = h.cur
if cur != nil {
cur.RLock()
root = cur.Root
cur.RUnlock()
}
h.RUnlock()
if cur != nil && root != nil {
return root, nil
}
time.Sleep(DefaultCheckDuration)
case <-ctxt.Done():
return nil, ctxt.Err()
}
}
}
// SetActive sets the currently active frame after a successful navigation.
func (h *TargetHandler) SetActive(ctxt context.Context, id cdp.FrameID) error {
var err error
// get frame
f, err := h.WaitFrame(ctxt, id)
if err != nil {
return err
}
h.Lock()
defer h.Unlock()
h.cur = f
return nil
}
// WaitFrame waits for a frame to be loaded using the provided context.
func (h *TargetHandler) WaitFrame(ctxt context.Context, id cdp.FrameID) (*cdp.Frame, error) {
// TODO: fix this
timeout := time.After(10 * time.Second)
for {
select {
default:
var f *cdp.Frame
var ok bool
h.RLock()
if id == cdp.EmptyFrameID {
f, ok = h.cur, h.cur != nil
} else {
f, ok = h.frames[id]
}
h.RUnlock()
if ok {
return f, nil
}
time.Sleep(DefaultCheckDuration)
case <-ctxt.Done():
return nil, ctxt.Err()
case <-timeout:
return nil, fmt.Errorf("timeout waiting for frame `%s`", id)
}
}
}
// WaitNode waits for a node to be loaded using the provided context.
func (h *TargetHandler) WaitNode(ctxt context.Context, f *cdp.Frame, id cdp.NodeID) (*cdp.Node, error) {
// TODO: fix this
timeout := time.After(10 * time.Second)
for {
select {
default:
var n *cdp.Node
var ok bool
f.RLock()
n, ok = f.Nodes[id]
f.RUnlock()
if n != nil && ok {
return n, nil
}
time.Sleep(DefaultCheckDuration)
case <-ctxt.Done():
return nil, ctxt.Err()
case <-timeout:
return nil, fmt.Errorf("timeout waiting for node `%d`", id)
}
}
}
// pageEvent handles incoming page events.
func (h *TargetHandler) pageEvent(ctxt context.Context, ev interface{}) {
defer h.pageWaitGroup.Done()
var id cdp.FrameID
var op frameOp
switch e := ev.(type) {
case *page.EventFrameNavigated:
h.Lock()
h.frames[e.Frame.ID] = e.Frame
if h.cur != nil && h.cur.ID == e.Frame.ID {
h.cur = e.Frame
}
h.Unlock()
return
case *page.EventFrameAttached:
id, op = e.FrameID, frameAttached(e.ParentFrameID)
case *page.EventFrameDetached:
id, op = e.FrameID, frameDetached
case *page.EventFrameStartedLoading:
id, op = e.FrameID, frameStartedLoading
case *page.EventFrameStoppedLoading:
id, op = e.FrameID, frameStoppedLoading
case *page.EventFrameScheduledNavigation:
id, op = e.FrameID, frameScheduledNavigation
case *page.EventFrameClearedScheduledNavigation:
id, op = e.FrameID, frameClearedScheduledNavigation
// ignored events
case *page.EventDomContentEventFired:
return
case *page.EventLoadEventFired:
return
case *page.EventFrameResized:
return
case *page.EventLifecycleEvent:
return
default:
h.errf("unhandled page event %s", reflect.TypeOf(ev))
return
}
f, err := h.WaitFrame(ctxt, id)
if err != nil {
h.errf("could not get frame %s: %v", id, err)
return
}
h.Lock()
defer h.Unlock()
f.Lock()
defer f.Unlock()
op(f)
}
// domEvent handles incoming DOM events.
func (h *TargetHandler) domEvent(ctxt context.Context, ev interface{}) {
defer h.domWaitGroup.Done()
// wait current frame
f, err := h.WaitFrame(ctxt, cdp.EmptyFrameID)
if err != nil {
h.errf("could not process DOM event %s: %v", reflect.TypeOf(ev), err)
return
}
var id cdp.NodeID
var op nodeOp
switch e := ev.(type) {
case *dom.EventSetChildNodes:
id, op = e.ParentID, setChildNodes(f.Nodes, e.Nodes)
case *dom.EventAttributeModified:
id, op = e.NodeID, attributeModified(e.Name, e.Value)
case *dom.EventAttributeRemoved:
id, op = e.NodeID, attributeRemoved(e.Name)
case *dom.EventInlineStyleInvalidated:
if len(e.NodeIds) == 0 {
return
}
id, op = e.NodeIds[0], inlineStyleInvalidated(e.NodeIds[1:])
case *dom.EventCharacterDataModified:
id, op = e.NodeID, characterDataModified(e.CharacterData)
case *dom.EventChildNodeCountUpdated:
id, op = e.NodeID, childNodeCountUpdated(e.ChildNodeCount)
case *dom.EventChildNodeInserted:
if e.PreviousNodeID != cdp.EmptyNodeID {
_, err = h.WaitNode(ctxt, f, e.PreviousNodeID)
if err != nil {
return
}
}
id, op = e.ParentNodeID, childNodeInserted(f.Nodes, e.PreviousNodeID, e.Node)
case *dom.EventChildNodeRemoved:
id, op = e.ParentNodeID, childNodeRemoved(f.Nodes, e.NodeID)
case *dom.EventShadowRootPushed:
id, op = e.HostID, shadowRootPushed(f.Nodes, e.Root)
case *dom.EventShadowRootPopped:
id, op = e.HostID, shadowRootPopped(f.Nodes, e.RootID)
case *dom.EventPseudoElementAdded:
id, op = e.ParentID, pseudoElementAdded(f.Nodes, e.PseudoElement)
case *dom.EventPseudoElementRemoved:
id, op = e.ParentID, pseudoElementRemoved(f.Nodes, e.PseudoElementID)
case *dom.EventDistributedNodesUpdated:
id, op = e.InsertionPointID, distributedNodesUpdated(e.DistributedNodes)
default:
h.errf("unhandled node event %s", reflect.TypeOf(ev))
return
}
// retrieve node
n, err := h.WaitNode(ctxt, f, id)
if err != nil {
s := strings.TrimSuffix(goruntime.FuncForPC(reflect.ValueOf(op).Pointer()).Name(), ".func1")
i := strings.LastIndex(s, ".")
if i != -1 {
s = s[i+1:]
}
h.errf("could not perform (%s) operation on node %d (wait node): %v", s, id, err)
return
}
h.Lock()
defer h.Unlock()
f.Lock()
defer f.Unlock()
op(n)
}

View File

@ -3,13 +3,12 @@ package chromedp
import (
"context"
"fmt"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/chromedp/kb"
"git.loafle.net/commons_go/chromedp/kb"
)
// MouseAction is a mouse action.
@ -27,7 +26,7 @@ func MouseAction(typ input.MouseType, x, y int64, opts ...MouseOption) Action {
// MouseClickXY sends a left mouse button click (ie, mousePressed and
// mouseReleased event) at the X, Y location.
func MouseClickXY(x, y int64, opts ...MouseOption) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
me := &input.DispatchMouseEventParams{
Type: input.MousePressed,
X: float64(x),
@ -41,13 +40,12 @@ func MouseClickXY(x, y int64, opts ...MouseOption) Action {
me = o(me)
}
err := me.Do(ctxt, h)
if err != nil {
if err := me.Do(ctx, h); err != nil {
return err
}
me.Type = input.MouseReleased
return me.Do(ctxt, h)
return me.Do(ctx, h)
})
}
@ -57,16 +55,14 @@ func MouseClickXY(x, y int64, opts ...MouseOption) Action {
// Note that the window will be scrolled if the node is not within the window's
// viewport.
func MouseClickNode(n *cdp.Node, opts ...MouseOption) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
var err error
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
var pos []int
err = EvaluateAsDevTools(fmt.Sprintf(scrollIntoViewJS, n.FullXPath()), &pos).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(scrollIntoViewJS, n.FullXPath()), &pos).Do(ctx, h)
if err != nil {
return err
}
box, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctxt, h)
box, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctx, h)
if err != nil {
return err
}
@ -84,7 +80,7 @@ func MouseClickNode(n *cdp.Node, opts ...MouseOption) Action {
x /= int64(c / 2)
y /= int64(c / 2)
return MouseClickXY(x, y, opts...).Do(ctxt, h)
return MouseClickXY(x, y, opts...).Do(ctx, h)
})
}
@ -153,19 +149,13 @@ func ClickCount(n int) MouseOption {
// Please see the chromedp/kb package for implementation details and the list
// of well-known keys.
func KeyAction(keys string, opts ...KeyOption) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
var err error
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
for _, r := range keys {
for _, k := range kb.Encode(r) {
err = k.Do(ctxt, h)
if err != nil {
if err := k.Do(ctx, h); err != nil {
return err
}
}
// TODO: move to context
time.Sleep(5 * time.Millisecond)
}
return nil
@ -174,13 +164,13 @@ func KeyAction(keys string, opts ...KeyOption) Action {
// KeyActionNode dispatches a key event on a node.
func KeyActionNode(n *cdp.Node, keys string, opts ...KeyOption) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
err := dom.Focus().WithNodeID(n.NodeID).Do(ctxt, h)
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
err := dom.Focus().WithNodeID(n.NodeID).Do(ctx, h)
if err != nil {
return err
}
return KeyAction(keys, opts...).Do(ctxt, h)
return KeyAction(keys, opts...).Do(ctx, h)
})
}

View File

@ -4,35 +4,28 @@ import (
"fmt"
"strconv"
"testing"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/input"
)
const (
// inViewportJS is a javascript snippet that will get the specified node
// position relative to the viewport and returns true if the specified node
// is within the window's viewport.
inViewportJS = `(function(a) {
// inViewportJS is a javascript snippet that will get the specified node
// position relative to the viewport and returns true if the specified node
// is within the window's viewport.
const inViewportJS = `(function(a) {
var r = a[0].getBoundingClientRect();
return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
})($x('%s'))`
)
func TestMouseClickXY(t *testing.T) {
var err error
t.Parallel()
c := testAllocate(t, "input.html")
defer c.Release()
ctx, cancel := testAllocate(t, "input.html")
defer cancel()
err = c.Run(defaultContext, Sleep(100*time.Millisecond))
if err != nil {
if err := Run(ctx, WaitVisible(`#input1`, ByID)); err != nil {
t.Fatal(err)
}
tests := []struct {
x, y int64
}{
@ -43,18 +36,14 @@ func TestMouseClickXY(t *testing.T) {
}
for i, test := range tests {
err = c.Run(defaultContext, MouseClickXY(test.x, test.y))
if err != nil {
t.Fatalf("test %d got error: %v", i, err)
}
time.Sleep(50 * time.Millisecond)
var xstr, ystr string
err = c.Run(defaultContext, Value("#input1", &xstr, ByID))
if err != nil {
if err := Run(ctx,
MouseClickXY(test.x, test.y),
Value("#input1", &xstr, ByID),
); err != nil {
t.Fatalf("test %d got error: %v", i, err)
}
x, err := strconv.ParseInt(xstr, 10, 64)
if err != nil {
t.Fatalf("test %d got error: %v", i, err)
@ -62,11 +51,10 @@ func TestMouseClickXY(t *testing.T) {
if x != test.x {
t.Fatalf("test %d expected x to be: %d, got: %d", i, test.x, x)
}
err = c.Run(defaultContext, Value("#input2", &ystr, ByID))
if err != nil {
if err := Run(ctx, Value("#input2", &ystr, ByID)); err != nil {
t.Fatalf("test %d got error: %v", i, err)
}
y, err := strconv.ParseInt(ystr, 10, 64)
if err != nil {
t.Fatalf("test %d got error: %v", i, err)
@ -78,6 +66,8 @@ func TestMouseClickXY(t *testing.T) {
}
func TestMouseClickNode(t *testing.T) {
t.Parallel()
tests := []struct {
sel, exp string
opt MouseOption
@ -86,40 +76,34 @@ func TestMouseClickNode(t *testing.T) {
{"button2", "foo", ButtonType(input.ButtonNone), ByID},
{"button2", "bar", ButtonType(input.ButtonLeft), ByID},
{"button2", "bar-middle", ButtonType(input.ButtonMiddle), ByID},
{"input3", "foo", ButtonModifiers(input.ModifierNone), ByID},
{"input3", "bar-right", ButtonType(input.ButtonRight), ByID},
{"input3", "bar-right", ButtonModifiers(input.ModifierNone), ByID},
{"input3", "bar-right", Button("right"), ByID},
}
for i, test := range tests {
t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
test := test
t.Parallel()
c := testAllocate(t, "input.html")
defer c.Release()
ctx, cancel := testAllocate(t, "input.html")
defer cancel()
var err error
var nodes []*cdp.Node
err = c.Run(defaultContext, Nodes(test.sel, &nodes, test.by))
if err != nil {
if err := Run(ctx, Nodes(test.sel, &nodes, test.by)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected nodes to have exactly 1 element, got: %d", len(nodes))
}
err = c.Run(defaultContext, MouseClickNode(nodes[0], test.opt))
if err != nil {
t.Fatalf("got error: %v", err)
}
time.Sleep(50 * time.Millisecond)
var value string
err = c.Run(defaultContext, Value("#input3", &value, ByID))
if err != nil {
if err := Run(ctx,
MouseClickNode(nodes[0], test.opt),
Value("#input3", &value, ByID),
); err != nil {
t.Fatalf("got error: %v", err)
}
if value != test.exp {
t.Fatalf("expected to have value %s, got: %s", test.exp, value)
}
@ -128,6 +112,8 @@ func TestMouseClickNode(t *testing.T) {
}
func TestMouseClickOffscreenNode(t *testing.T) {
t.Parallel()
tests := []struct {
sel string
exp int
@ -139,45 +125,42 @@ func TestMouseClickOffscreenNode(t *testing.T) {
}
for i, test := range tests {
t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
test := test
t.Parallel()
c := testAllocate(t, "input.html")
defer c.Release()
ctx, cancel := testAllocate(t, "input.html")
defer cancel()
var err error
var nodes []*cdp.Node
err = c.Run(defaultContext, Nodes(test.sel, &nodes, test.by))
if err != nil {
if err := Run(ctx, Nodes(test.sel, &nodes, test.by)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected nodes to have exactly 1 element, got: %d", len(nodes))
}
var ok bool
err = c.Run(defaultContext, EvaluateAsDevTools(fmt.Sprintf(inViewportJS, nodes[0].FullXPath()), &ok))
if err != nil {
if err := Run(ctx, EvaluateAsDevTools(fmt.Sprintf(inViewportJS, nodes[0].FullXPath()), &ok)); err != nil {
t.Fatalf("got error: %v", err)
}
if ok {
t.Fatal("expected node to be offscreen")
}
for i := test.exp; i > 0; i-- {
err = c.Run(defaultContext, MouseClickNode(nodes[0]))
if err != nil {
if err := Run(ctx, MouseClickNode(nodes[0])); err != nil {
t.Fatalf("got error: %v", err)
}
}
time.Sleep(100 * time.Millisecond)
var value int
err = c.Run(defaultContext, Evaluate("window.document.test_i", &value))
if err != nil {
if err := Run(ctx, Evaluate("window.document.test_i", &value)); err != nil {
t.Fatalf("got error: %v", err)
}
if value != test.exp {
t.Fatalf("expected to have value %d, got: %d", test.exp, value)
}
@ -186,6 +169,8 @@ func TestMouseClickOffscreenNode(t *testing.T) {
}
func TestKeyAction(t *testing.T) {
t.Parallel()
tests := []struct {
sel, exp string
by QueryOption
@ -199,37 +184,33 @@ func TestKeyAction(t *testing.T) {
}
for i, test := range tests {
t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
test := test
t.Parallel()
c := testAllocate(t, "input.html")
defer c.Release()
ctx, cancel := testAllocate(t, "input.html")
defer cancel()
var err error
var nodes []*cdp.Node
err = c.Run(defaultContext, Nodes(test.sel, &nodes, test.by))
if err != nil {
if err := Run(ctx, Nodes(test.sel, &nodes, test.by)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected nodes to have exactly 1 element, got: %d", len(nodes))
}
err = c.Run(defaultContext, Focus(test.sel, test.by))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, KeyAction(test.exp))
if err != nil {
if err := Run(ctx,
Focus(test.sel, test.by),
KeyAction(test.exp),
); err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value(test.sel, &value, test.by))
if err != nil {
if err := Run(ctx, Value(test.sel, &value, test.by)); err != nil {
t.Fatalf("got error: %v", err)
}
if value != test.exp {
t.Fatalf("expected to have value %s, got: %s", test.exp, value)
}
@ -238,6 +219,8 @@ func TestKeyAction(t *testing.T) {
}
func TestKeyActionNode(t *testing.T) {
t.Parallel()
tests := []struct {
sel, exp string
by QueryOption
@ -251,32 +234,29 @@ func TestKeyActionNode(t *testing.T) {
}
for i, test := range tests {
t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) {
t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
test := test
t.Parallel()
c := testAllocate(t, "input.html")
defer c.Release()
ctx, cancel := testAllocate(t, "input.html")
defer cancel()
var err error
var nodes []*cdp.Node
err = c.Run(defaultContext, Nodes(test.sel, &nodes, test.by))
if err != nil {
if err := Run(ctx, Nodes(test.sel, &nodes, test.by)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected nodes to have exactly 1 element, got: %d", len(nodes))
}
err = c.Run(defaultContext, KeyActionNode(nodes[0], test.exp))
if err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value(test.sel, &value, test.by))
if err != nil {
if err := Run(ctx,
KeyActionNode(nodes[0], test.exp),
Value(test.sel, &value, test.by),
); err != nil {
t.Fatalf("got error: %v", err)
}
if value != test.exp {
t.Fatalf("expected to have value %s, got: %s", test.exp, value)
}

View File

@ -16,7 +16,7 @@ import (
"strconv"
"strings"
"github.com/chromedp/chromedp/kb"
"git.loafle.net/commons_go/chromedp/kb"
)
var (
@ -72,8 +72,6 @@ func main() {
}
func run() error {
var err error
// special characters
keys := map[rune]kb.Key{
'\b': {"Backspace", "Backspace", "", "", int64('\b'), int64('\b'), false, false},
@ -82,8 +80,7 @@ func run() error {
}
// load keys
err = loadKeys(keys)
if err != nil {
if err := loadKeys(keys); err != nil {
return err
}
@ -94,24 +91,19 @@ func run() error {
}
// output
err = ioutil.WriteFile(
*flagOut,
if err := ioutil.WriteFile(*flagOut,
[]byte(fmt.Sprintf(hdr, *flagPkg, string(constBuf), string(mapBuf))),
0644,
)
if err != nil {
0644); err != nil {
return err
}
// format
err = exec.Command("goimports", "-w", *flagOut).Run()
if err != nil {
if err := exec.Command("goimports", "-w", *flagOut).Run(); err != nil {
return err
}
// format
err = exec.Command("gofmt", "-s", "-w", *flagOut).Run()
if err != nil {
if err := exec.Command("gofmt", "-s", "-w", *flagOut).Run(); err != nil {
return err
}
@ -120,8 +112,6 @@ func run() error {
// loadKeys loads the dom key definitions from the chromium source tree.
func loadKeys(keys map[rune]kb.Key) error {
var err error
// load key converter data
keycodeConverterMap, err := loadKeycodeConverterData()
if err != nil {
@ -444,8 +434,6 @@ var defineRE = regexp.MustCompile(`(?m)^#define\s+(.+?)\s+([0-9A-Fx]+)`)
// loadPosixWinKeyboardCodes loads the native and windows keyboard scan codes
// mapped to the DOM key.
func loadPosixWinKeyboardCodes() (map[string][]int64, error) {
var err error
lookup := map[string]string{
// mac alias
"VKEY_LWIN": "0x5B",

View File

@ -5,6 +5,7 @@ package kb
//go:generate go run gen.go -out keys.go -pkg kb
import (
"runtime"
"unicode"
"github.com/chromedp/cdproto/input"
@ -97,6 +98,9 @@ func Encode(r rune) []*input.DispatchKeyEventParams {
NativeVirtualKeyCode: v.Native,
WindowsVirtualKeyCode: v.Windows,
}
if runtime.GOOS == "darwin" {
keyDown.NativeVirtualKeyCode = 0
}
if v.Shift {
keyDown.Modifiers |= input.ModifierShift
}

39
nav.go
View File

@ -10,18 +10,9 @@ import (
// Navigate navigates the current frame.
func Navigate(urlstr string) Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
th, ok := h.(*TargetHandler)
if !ok {
return ErrInvalidHandler
}
frameID, _, _, err := page.Navigate(urlstr).Do(ctxt, th)
if err != nil {
return err
}
return th.SetActive(ctxt, frameID)
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
_, _, _, err := page.Navigate(urlstr).Do(ctx, h)
return err
})
}
@ -32,9 +23,9 @@ func NavigationEntries(currentIndex *int64, entries *[]*page.NavigationEntry) Ac
panic("currentIndex and entries cannot be nil")
}
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
var err error
*currentIndex, *entries, err = page.GetNavigationHistory().Do(ctxt, h)
*currentIndex, *entries, err = page.GetNavigationHistory().Do(ctx, h)
return err
})
}
@ -47,8 +38,8 @@ func NavigateToHistoryEntry(entryID int64) Action {
// NavigateBack navigates the current frame backwards in its history.
func NavigateBack() Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
cur, entries, err := page.GetNavigationHistory().Do(ctxt, h)
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
cur, entries, err := page.GetNavigationHistory().Do(ctx, h)
if err != nil {
return err
}
@ -57,14 +48,14 @@ func NavigateBack() Action {
return errors.New("invalid navigation entry")
}
return page.NavigateToHistoryEntry(entries[cur-1].ID).Do(ctxt, h)
return page.NavigateToHistoryEntry(entries[cur-1].ID).Do(ctx, h)
})
}
// NavigateForward navigates the current frame forwards in its history.
func NavigateForward() Action {
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
cur, entries, err := page.GetNavigationHistory().Do(ctxt, h)
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
cur, entries, err := page.GetNavigationHistory().Do(ctx, h)
if err != nil {
return err
}
@ -73,7 +64,7 @@ func NavigateForward() Action {
return errors.New("invalid navigation entry")
}
return page.NavigateToHistoryEntry(entries[cur+1].ID).Do(ctxt, h)
return page.NavigateToHistoryEntry(entries[cur+1].ID).Do(ctx, h)
})
}
@ -95,9 +86,9 @@ func CaptureScreenshot(res *[]byte) Action {
panic("res cannot be nil")
}
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
var err error
*res, err = page.CaptureScreenshot().Do(ctxt, h)
*res, err = page.CaptureScreenshot().Do(ctx, h)
return err
})
}
@ -108,9 +99,9 @@ func CaptureScreenshot(res *[]byte) Action {
panic("id cannot be nil")
}
return ActionFunc(func(ctxt context.Context, h cdp.Executor) error {
return ActionFunc(func(ctx context.Context, h cdp.Executor) error {
var err error
*id, err = page.AddScriptToEvaluateOnLoad(source).Do(ctxt, h)
*id, err = page.AddScriptToEvaluateOnLoad(source).Do(ctx, h)
return err
})
}

View File

@ -1,47 +1,43 @@
package chromedp
import (
"bytes"
"fmt"
"image"
_ "image/png"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/page"
)
func TestNavigate(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
expurl, exptitle := testdataDir+"/image.html", "this is title"
err = c.Run(defaultContext, Navigate(expurl))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, WaitVisible(`#icon-brankas`, ByID))
if err != nil {
t.Fatal(err)
}
ctx, cancel := testAllocate(t, "image.html")
defer cancel()
var urlstr string
err = c.Run(defaultContext, Location(&urlstr))
if err != nil {
if err := Run(ctx,
WaitVisible(`#icon-brankas`, ByID),
Location(&urlstr),
); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(urlstr, expurl) {
if !strings.HasSuffix(urlstr, "image.html") {
t.Errorf("expected to be on image.html, at: %s", urlstr)
}
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
if err := Run(ctx, Title(&title)); err != nil {
t.Fatal(err)
}
exptitle := "this is title"
if title != exptitle {
t.Errorf("expected title to contain google, instead title is: %s", title)
}
@ -50,21 +46,19 @@ func TestNavigate(t *testing.T) {
func TestNavigationEntries(t *testing.T) {
t.Parallel()
var err error
ctx, cancel := testAllocate(t, "")
defer cancel()
c := testAllocate(t, "")
defer c.Release()
tests := []string{
"form.html",
"image.html",
tests := []struct {
file, waitID string
}{
{"form.html", "#form"},
{"image.html", "#icon-brankas"},
}
var entries []*page.NavigationEntry
var index int64
err = c.Run(defaultContext, NavigationEntries(&index, &entries))
if err != nil {
if err := Run(ctx, NavigationEntries(&index, &entries)); err != nil {
t.Fatal(err)
}
@ -76,24 +70,19 @@ func TestNavigationEntries(t *testing.T) {
}
expIdx, expEntries := 1, 2
for i, url := range tests {
err = c.Run(defaultContext, Navigate(testdataDir+"/"+url))
if err != nil {
for i, test := range tests {
if err := Run(ctx,
Navigate(testdataDir+"/"+test.file),
WaitVisible(test.waitID, ByID),
NavigationEntries(&index, &entries),
); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, NavigationEntries(&index, &entries))
if err != nil {
t.Fatal(err)
}
if len(entries) != expEntries {
t.Errorf("test %d expected to have %d navigation entry: got %d", i, expEntries, len(entries))
}
if index != int64(i+1) {
t.Errorf("test %d expected navigation index is %d, got: %d", i, i, index)
if want := int64(i + 1); index != want {
t.Errorf("test %d expected navigation index is %d, got: %d", i, want, index)
}
expIdx++
@ -104,42 +93,27 @@ func TestNavigationEntries(t *testing.T) {
func TestNavigateToHistoryEntry(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
ctx, cancel := testAllocate(t, "image.html")
defer cancel()
var entries []*page.NavigationEntry
var index int64
err = c.Run(defaultContext, Navigate(testdataDir+"/image.html"))
if err != nil {
if err := Run(ctx,
WaitVisible(`#icon-brankas`, ByID), // for image.html
NavigationEntries(&index, &entries),
Navigate(testdataDir+"/form.html"),
WaitVisible(`#form`, ByID), // for form.html
); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, NavigationEntries(&index, &entries))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, NavigateToHistoryEntry(entries[index].ID))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
if err := Run(ctx,
NavigateToHistoryEntry(entries[index].ID),
WaitVisible(`#icon-brankas`, ByID), // for image.html
Title(&title),
); err != nil {
t.Fatal(err)
}
if title != entries[index].Title {
@ -150,43 +124,24 @@ func TestNavigateToHistoryEntry(t *testing.T) {
func TestNavigateBack(t *testing.T) {
t.Parallel()
var err error
ctx, cancel := testAllocate(t, "form.html")
defer cancel()
c := testAllocate(t, "")
defer c.Release()
var title, exptitle string
if err := Run(ctx,
WaitVisible(`#form`, ByID), // for form.html
Title(&exptitle),
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
Navigate(testdataDir+"/image.html"),
WaitVisible(`#icon-brankas`, ByID), // for image.html
NavigateBack(),
WaitVisible(`#form`, ByID), // for form.html
Title(&title),
); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var exptitle string
err = c.Run(defaultContext, Title(&exptitle))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Navigate(testdataDir+"/image.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, NavigateBack())
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
t.Fatal(err)
}
if title != exptitle {
t.Errorf("expected title to be %s, instead title is: %s", exptitle, title)
}
@ -195,50 +150,27 @@ func TestNavigateBack(t *testing.T) {
func TestNavigateForward(t *testing.T) {
t.Parallel()
var err error
ctx, cancel := testAllocate(t, "form.html")
defer cancel()
c := testAllocate(t, "")
defer c.Release()
var title, exptitle string
if err := Run(ctx,
WaitVisible(`#form`, ByID), // for form.html
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
Navigate(testdataDir+"/image.html"),
WaitVisible(`#icon-brankas`, ByID), // for image.html
Title(&exptitle),
NavigateBack(),
WaitVisible(`#form`, ByID), // for form.html
NavigateForward(),
WaitVisible(`#icon-brankas`, ByID), // for image.html
Title(&title),
); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, Navigate(testdataDir+"/image.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var exptitle string
err = c.Run(defaultContext, Title(&exptitle))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, NavigateBack())
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
err = c.Run(defaultContext, NavigateForward())
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
t.Fatal(err)
}
if title != exptitle {
t.Errorf("expected title to be %s, instead title is: %s", exptitle, title)
}
@ -247,18 +179,9 @@ func TestNavigateForward(t *testing.T) {
func TestStop(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Stop())
if err != nil {
ctx, cancel := testAllocate(t, "form.html")
defer cancel()
if err := Run(ctx, Stop()); err != nil {
t.Fatal(err)
}
}
@ -266,36 +189,38 @@ func TestStop(t *testing.T) {
func TestReload(t *testing.T) {
t.Parallel()
var err error
count := 0
// create test server
mux := http.NewServeMux()
mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
fmt.Fprintf(res, `<html>
<head>
<title>Title</title>
</head>
<body>
<div id="count%d"></div>
</body></html`, count)
count++
})
s := httptest.NewServer(mux)
defer s.Close()
c := testAllocate(t, "")
defer c.Release()
ctx, cancel := testAllocate(t, "")
defer cancel()
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
var title, exptitle string
if err := Run(ctx,
Navigate(s.URL),
WaitReady(`#count0`, ByID),
Title(&exptitle),
Reload(),
WaitReady(`#count1`, ByID),
Title(&title),
); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var exptitle string
err = c.Run(defaultContext, Title(&exptitle))
if err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Reload())
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
t.Fatal(err)
}
if title != exptitle {
t.Errorf("expected title to be %s, instead title is: %s", exptitle, title)
}
@ -304,51 +229,47 @@ func TestReload(t *testing.T) {
func TestCaptureScreenshot(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
err = c.Run(defaultContext, Navigate(testdataDir+"/image.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
ctx, cancel := testAllocate(t, "image.html")
defer cancel()
// set the viewport size, to know what screenshot size to expect
width, height := 650, 450
var buf []byte
err = c.Run(defaultContext, CaptureScreenshot(&buf))
if err != nil {
if err := Run(ctx,
emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false),
WaitVisible(`#icon-brankas`, ByID), // for image.html
CaptureScreenshot(&buf),
); err != nil {
t.Fatal(err)
}
if len(buf) == 0 {
t.Fatal("failed to capture screenshot")
config, format, err := image.DecodeConfig(bytes.NewReader(buf))
if err != nil {
t.Fatal(err)
}
if want := "png"; format != want {
t.Fatalf("expected format to be %q, got %q", want, format)
}
if config.Width != width || config.Height != height {
t.Fatalf("expected dimensions to be %d*%d, got %d*%d",
width, height, config.Width, config.Height)
}
//TODO: test image
}
/*func TestAddOnLoadScript(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
ctx, cancel := testAllocate(t, "")
defer cancel()
var scriptID page.ScriptIdentifier
err = c.Run(defaultContext, AddOnLoadScript(`window.alert("TEST")`, &scriptID))
if err != nil {
if err := Run(ctx,
AddOnLoadScript(`window.alert("TEST")`, &scriptID),
Navigate(testdataDir+"/form.html"),
); err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if scriptID == "" {
t.Fatal("got empty script ID")
}
@ -358,57 +279,40 @@ func TestCaptureScreenshot(t *testing.T) {
func TestRemoveOnLoadScript(t *testing.T) {
t.Parallel()
var err error
c := testAllocate(t, "")
defer c.Release()
ctx, cancel := testAllocate(t, "")
defer cancel()
var scriptID page.ScriptIdentifier
err = c.Run(defaultContext, AddOnLoadScript(`window.alert("TEST")`, &scriptID))
if err != nil {
if err := Run(ctx, AddOnLoadScript(`window.alert("TEST")`, &scriptID)); err != nil {
t.Fatal(err)
}
if scriptID == "" {
t.Fatal("got empty script ID")
}
err = c.Run(defaultContext, RemoveOnLoadScript(scriptID))
if err != nil {
if err := Run(ctx,
RemoveOnLoadScript(scriptID),
Navigate(testdataDir+"/form.html"),
); err != nil {
t.Fatal(err)
}
err = c.Run(defaultContext, Navigate(testdataDir+"/form.html"))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}*/
func TestLocation(t *testing.T) {
t.Parallel()
var err error
expurl := testdataDir + "/form.html"
c := testAllocate(t, "")
defer c.Release()
err = c.Run(defaultContext, Navigate(expurl))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
ctx, cancel := testAllocate(t, "form.html")
defer cancel()
var urlstr string
err = c.Run(defaultContext, Location(&urlstr))
if err != nil {
if err := Run(ctx,
WaitVisible(`#form`, ByID), // for form.html
Location(&urlstr),
); err != nil {
t.Fatal(err)
}
if urlstr != expurl {
if !strings.HasSuffix(urlstr, "form.html") {
t.Fatalf("expected to be on form.html, got: %s", urlstr)
}
}
@ -416,26 +320,35 @@ func TestLocation(t *testing.T) {
func TestTitle(t *testing.T) {
t.Parallel()
var err error
expurl, exptitle := testdataDir+"/image.html", "this is title"
c := testAllocate(t, "")
defer c.Release()
err = c.Run(defaultContext, Navigate(expurl))
if err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
ctx, cancel := testAllocate(t, "image.html")
defer cancel()
var title string
err = c.Run(defaultContext, Title(&title))
if err != nil {
if err := Run(ctx,
WaitVisible(`#icon-brankas`, ByID), // for image.html
Title(&title),
); err != nil {
t.Fatal(err)
}
exptitle := "this is title"
if title != exptitle {
t.Fatalf("expected title to be %s, got: %s", exptitle, title)
}
}
func TestLoadIframe(t *testing.T) {
t.Parallel()
ctx, cancel := testAllocate(t, "iframe.html")
defer cancel()
if err := Run(ctx, Tasks{
// TODO: remove the sleep once we have better support for
// iframes.
Sleep(10 * time.Millisecond),
// WaitVisible(`#form`, ByID), // for the nested form.html
}); err != nil {
t.Fatal(err)
}
}

202
pool.go
View File

@ -1,202 +0,0 @@
package chromedp
import (
"context"
"fmt"
"log"
"sync"
"github.com/chromedp/chromedp/runner"
)
// Pool manages a pool of running Chrome processes.
type Pool struct {
// start is the start port.
start int
// end is the end port.
end int
// res are the running chrome resources.
res map[int]*Res
// logging funcs
logf, debugf, errf func(string, ...interface{})
rw sync.RWMutex
}
// NewPool creates a new Chrome runner pool.
func NewPool(opts ...PoolOption) (*Pool, error) {
p := &Pool{
start: DefaultPoolStartPort,
end: DefaultPoolEndPort,
res: make(map[int]*Res),
logf: log.Printf,
debugf: func(string, ...interface{}) {},
}
// apply opts
for _, o := range opts {
if err := o(p); err != nil {
return nil, err
}
}
if p.errf == nil {
p.errf = func(s string, v ...interface{}) {
p.logf("ERROR: "+s, v...)
}
}
return p, nil
}
// Shutdown releases all the pool resources.
func (p *Pool) Shutdown() error {
p.rw.Lock()
defer p.rw.Unlock()
for _, r := range p.res {
r.cancel()
}
return nil
}
// Allocate creates a new process runner and returns it.
func (p *Pool) Allocate(ctxt context.Context, opts ...runner.CommandLineOption) (*Res, error) {
var err error
r := p.next(ctxt)
p.debugf("pool allocating %d", r.port)
// create runner
r.r, err = runner.New(append([]runner.CommandLineOption{
runner.HeadlessPathPort("", r.port),
}, opts...)...)
if err != nil {
defer r.Release()
p.errf("pool could not allocate runner on port %d: %v", r.port, err)
return nil, err
}
// start runner
err = r.r.Start(r.ctxt)
if err != nil {
defer r.Release()
p.errf("pool could not start runner on port %d: %v", r.port, err)
return nil, err
}
// setup cdp
r.c, err = New(
r.ctxt, WithRunner(r.r),
WithLogf(p.logf), WithDebugf(p.debugf), WithErrorf(p.errf),
)
if err != nil {
defer r.Release()
p.errf("pool could not connect to %d: %v", r.port, err)
return nil, err
}
return r, nil
}
// next returns the next available res.
func (p *Pool) next(ctxt context.Context) *Res {
p.rw.Lock()
defer p.rw.Unlock()
var found bool
var i int
for i = p.start; i < p.end; i++ {
if _, ok := p.res[i]; !ok {
found = true
break
}
}
if !found {
panic("no ports available")
}
r := &Res{
p: p,
port: i,
}
r.ctxt, r.cancel = context.WithCancel(ctxt)
p.res[i] = r
return r
}
// Res is a pool resource.
type Res struct {
p *Pool
ctxt context.Context
cancel func()
port int
r *runner.Runner
c *CDP
}
// Release releases the pool resource.
func (r *Res) Release() error {
r.cancel()
var err error
if r.c != nil {
err = r.c.Wait()
}
defer r.p.debugf("pool released %d", r.port)
r.p.rw.Lock()
defer r.p.rw.Unlock()
delete(r.p.res, r.port)
return err
}
// Port returns the allocated port for the pool resource.
func (r *Res) Port() int {
return r.port
}
// URL returns a formatted URL for the pool resource.
func (r *Res) URL() string {
return fmt.Sprintf("http://localhost:%d/json", r.port)
}
// CDP returns the actual CDP instance.
func (r *Res) CDP() *CDP {
return r.c
}
// Run runs an action.
func (r *Res) Run(ctxt context.Context, a Action) error {
return r.c.Run(ctxt, a)
}
// PoolOption is a pool option.
type PoolOption func(*Pool) error
// PortRange is a pool option to set the port range to use.
func PortRange(start, end int) PoolOption {
return func(p *Pool) error {
p.start = start
p.end = end
return nil
}
}
// PoolLog is a pool option to set the logging to use for the pool.
func PoolLog(logf, debugf, errf func(string, ...interface{})) PoolOption {
return func(p *Pool) error {
p.logf, p.debugf, p.errf = logf, debugf, errf
return nil
}
}

View File

@ -25,7 +25,7 @@ func Nodes(sel interface{}, nodes *[]*cdp.Node, opts ...QueryOption) Action {
panic("nodes cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, n ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, n ...*cdp.Node) error {
*nodes = n
return nil
}, opts...)
@ -37,7 +37,7 @@ func NodeIDs(sel interface{}, ids *[]cdp.NodeID, opts ...QueryOption) Action {
panic("nodes cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
nodeIDs := make([]cdp.NodeID, len(nodes))
for i, n := range nodes {
nodeIDs[i] = n.NodeID
@ -51,24 +51,24 @@ func NodeIDs(sel interface{}, ids *[]cdp.NodeID, opts ...QueryOption) Action {
// Focus focuses the first node matching the selector.
func Focus(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return dom.Focus().WithNodeID(nodes[0].NodeID).Do(ctxt, h)
return dom.Focus().WithNodeID(nodes[0].NodeID).Do(ctx, h)
}, opts...)
}
// Blur unfocuses (blurs) the first node matching the selector.
func Blur(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var res bool
err := EvaluateAsDevTools(fmt.Sprintf(blurJS, nodes[0].FullXPath()), &res).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(blurJS, nodes[0].FullXPath()), &res).Do(ctx, h)
if err != nil {
return err
}
@ -87,12 +87,12 @@ func Dimensions(sel interface{}, model **dom.BoxModel, opts ...QueryOption) Acti
if model == nil {
panic("model cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var err error
*model, err = dom.GetBoxModel().WithNodeID(nodes[0].NodeID).Do(ctxt, h)
*model, err = dom.GetBoxModel().WithNodeID(nodes[0].NodeID).Do(ctx, h)
return err
}, opts...)
}
@ -103,18 +103,18 @@ func Text(sel interface{}, text *string, opts ...QueryOption) Action {
panic("text cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return EvaluateAsDevTools(fmt.Sprintf(textJS, nodes[0].FullXPath()), text).Do(ctxt, h)
return EvaluateAsDevTools(fmt.Sprintf(textJS, nodes[0].FullXPath()), text).Do(ctx, h)
}, opts...)
}
// Clear clears the values of any input/textarea nodes matching the selector.
func Clear(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
@ -154,7 +154,7 @@ func Clear(sel interface{}, opts ...QueryOption) Action {
a = dom.SetNodeValue(textID, "")
}
errs[i] = a.Do(ctxt, h)
errs[i] = a.Do(ctx, h)
}(i, n)
}
wg.Wait()
@ -190,7 +190,7 @@ func Attributes(sel interface{}, attributes *map[string]string, opts ...QueryOpt
panic("attributes cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
@ -219,7 +219,7 @@ func AttributesAll(sel interface{}, attributes *[]map[string]string, opts ...Que
panic("attributes cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
@ -243,7 +243,7 @@ func AttributesAll(sel interface{}, attributes *[]map[string]string, opts ...Que
// SetAttributes sets the element attributes for the first node matching the
// selector.
func SetAttributes(sel interface{}, attributes map[string]string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return errors.New("expected at least one element")
}
@ -254,7 +254,7 @@ func SetAttributes(sel interface{}, attributes map[string]string, opts ...QueryO
i++
}
return dom.SetAttributesAsText(nodes[0].NodeID, strings.Join(attrs, " ")).Do(ctxt, h)
return dom.SetAttributesAsText(nodes[0].NodeID, strings.Join(attrs, " ")).Do(ctx, h)
}, opts...)
}
@ -265,7 +265,7 @@ func AttributeValue(sel interface{}, name string, value *string, ok *bool, opts
panic("value cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return errors.New("expected at least one element")
}
@ -295,24 +295,24 @@ func AttributeValue(sel interface{}, name string, value *string, ok *bool, opts
// SetAttributeValue sets the element attribute with name to value for the
// first node matching the selector.
func SetAttributeValue(sel interface{}, name, value string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return dom.SetAttributeValue(nodes[0].NodeID, name, value).Do(ctxt, h)
return dom.SetAttributeValue(nodes[0].NodeID, name, value).Do(ctx, h)
}, opts...)
}
// RemoveAttribute removes the element attribute with name from the first node
// matching the selector.
func RemoveAttribute(sel interface{}, name string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return dom.RemoveAttribute(nodes[0].NodeID, name).Do(ctxt, h)
return dom.RemoveAttribute(nodes[0].NodeID, name).Do(ctx, h)
}, opts...)
}
@ -322,25 +322,25 @@ func JavascriptAttribute(sel interface{}, name string, res interface{}, opts ...
if res == nil {
panic("res cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return EvaluateAsDevTools(fmt.Sprintf(attributeJS, nodes[0].FullXPath(), name), res).Do(ctxt, h)
return EvaluateAsDevTools(fmt.Sprintf(attributeJS, nodes[0].FullXPath(), name), res).Do(ctx, h)
}, opts...)
}
// SetJavascriptAttribute sets the javascript attribute for the first node
// matching the selector.
func SetJavascriptAttribute(sel interface{}, name, value string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var res string
err := EvaluateAsDevTools(fmt.Sprintf(setAttributeJS, nodes[0].FullXPath(), name, value), &res).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(setAttributeJS, nodes[0].FullXPath(), name, value), &res).Do(ctx, h)
if err != nil {
return err
}
@ -370,24 +370,24 @@ func InnerHTML(sel interface{}, html *string, opts ...QueryOption) Action {
// Click sends a mouse click event to the first node matching the selector.
func Click(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return MouseClickNode(nodes[0]).Do(ctxt, h)
return MouseClickNode(nodes[0]).Do(ctx, h)
}, append(opts, NodeVisible)...)
}
// DoubleClick sends a mouse double click event to the first node matching the
// selector.
func DoubleClick(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return MouseClickNode(nodes[0], ClickCount(2)).Do(ctxt, h)
return MouseClickNode(nodes[0], ClickCount(2)).Do(ctx, h)
}, append(opts, NodeVisible)...)
}
@ -397,7 +397,7 @@ func DoubleClick(sel interface{}, opts ...QueryOption) Action {
// Note: when selector matches a input[type="file"] node, then dom.SetFileInputFiles
// is used to set the upload path of the input node to v.
func SendKeys(sel interface{}, v string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
@ -416,22 +416,22 @@ func SendKeys(sel interface{}, v string, opts ...QueryOption) Action {
// when working with input[type="file"], call dom.SetFileInputFiles
if n.NodeName == "INPUT" && typ == "file" {
return dom.SetFileInputFiles([]string{v}).WithNodeID(n.NodeID).Do(ctxt, h)
return dom.SetFileInputFiles([]string{v}).WithNodeID(n.NodeID).Do(ctx, h)
}
return KeyActionNode(n, v).Do(ctxt, h)
return KeyActionNode(n, v).Do(ctx, h)
}, append(opts, NodeVisible)...)
}
// SetUploadFiles sets the files to upload (ie, for a input[type="file"] node)
// for the first node matching the selector.
func SetUploadFiles(sel interface{}, files []string, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
return dom.SetFileInputFiles(files).WithNodeID(nodes[0].NodeID).Do(ctxt, h)
return dom.SetFileInputFiles(files).WithNodeID(nodes[0].NodeID).Do(ctx, h)
}, opts...)
}
@ -441,13 +441,13 @@ func Screenshot(sel interface{}, picbuf *[]byte, opts ...QueryOption) Action {
panic("picbuf cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
// get box model
box, err := dom.GetBoxModel().WithNodeID(nodes[0].NodeID).Do(ctxt, h)
box, err := dom.GetBoxModel().WithNodeID(nodes[0].NodeID).Do(ctx, h)
if err != nil {
return err
}
@ -459,13 +459,13 @@ func Screenshot(sel interface{}, picbuf *[]byte, opts ...QueryOption) Action {
// scroll to node position
var pos []int
err = EvaluateAsDevTools(fmt.Sprintf(scrollJS, int64(box.Margin[0]), int64(box.Margin[1])), &pos).Do(ctxt, h)
err = EvaluateAsDevTools(fmt.Sprintf(scrollJS, int64(box.Margin[0]), int64(box.Margin[1])), &pos).Do(ctx, h)
if err != nil {
return err
}
// take page screenshot
buf, err := page.CaptureScreenshot().Do(ctxt, h)
buf, err := page.CaptureScreenshot().Do(ctx, h)
if err != nil {
return err
}
@ -484,8 +484,7 @@ func Screenshot(sel interface{}, picbuf *[]byte, opts ...QueryOption) Action {
// encode
var croppedBuf bytes.Buffer
err = png.Encode(&croppedBuf, cropped)
if err != nil {
if err := png.Encode(&croppedBuf, cropped); err != nil {
return err
}
@ -498,13 +497,13 @@ func Screenshot(sel interface{}, picbuf *[]byte, opts ...QueryOption) Action {
// Submit is an action that submits the form of the first node matching the
// selector belongs to.
func Submit(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var res bool
err := EvaluateAsDevTools(fmt.Sprintf(submitJS, nodes[0].FullXPath()), &res).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(submitJS, nodes[0].FullXPath()), &res).Do(ctx, h)
if err != nil {
return err
}
@ -520,13 +519,13 @@ func Submit(sel interface{}, opts ...QueryOption) Action {
// Reset is an action that resets the form of the first node matching the
// selector belongs to.
func Reset(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var res bool
err := EvaluateAsDevTools(fmt.Sprintf(resetJS, nodes[0].FullXPath()), &res).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(resetJS, nodes[0].FullXPath()), &res).Do(ctx, h)
if err != nil {
return err
}
@ -545,12 +544,12 @@ func ComputedStyle(sel interface{}, style *[]*css.ComputedProperty, opts ...Quer
panic("style cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
computed, err := css.GetComputedStyleForNode(nodes[0].NodeID).Do(ctxt, h)
computed, err := css.GetComputedStyleForNode(nodes[0].NodeID).Do(ctx, h)
if err != nil {
return err
}
@ -568,7 +567,7 @@ func MatchedStyle(sel interface{}, style **css.GetMatchedStylesForNodeReturns, o
panic("style cannot be nil")
}
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
@ -577,7 +576,7 @@ func MatchedStyle(sel interface{}, style **css.GetMatchedStylesForNodeReturns, o
ret := &css.GetMatchedStylesForNodeReturns{}
ret.InlineStyle, ret.AttributesStyle, ret.MatchedCSSRules,
ret.PseudoElements, ret.Inherited, ret.CSSKeyframesRules,
err = css.GetMatchedStylesForNode(nodes[0].NodeID).Do(ctxt, h)
err = css.GetMatchedStylesForNode(nodes[0].NodeID).Do(ctx, h)
if err != nil {
return err
}
@ -590,13 +589,13 @@ func MatchedStyle(sel interface{}, style **css.GetMatchedStylesForNodeReturns, o
// ScrollIntoView scrolls the window to the first node matching the selector.
func ScrollIntoView(sel interface{}, opts ...QueryOption) Action {
return QueryAfter(sel, func(ctxt context.Context, h *TargetHandler, nodes ...*cdp.Node) error {
return QueryAfter(sel, func(ctx context.Context, h *Target, nodes ...*cdp.Node) error {
if len(nodes) < 1 {
return fmt.Errorf("selector `%s` did not return any nodes", sel)
}
var pos []int
err := EvaluateAsDevTools(fmt.Sprintf(scrollIntoViewJS, nodes[0].FullXPath()), &pos).Do(ctxt, h)
err := EvaluateAsDevTools(fmt.Sprintf(scrollIntoViewJS, nodes[0].FullXPath()), &pos).Do(ctx, h)
if err != nil {
return err
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
// +build darwin
package runner
const (
// DefaultChromePath is the default path to the Chrome application.
DefaultChromePath = `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
)
func findChromePath() string {
return DefaultChromePath
}

View File

@ -1,31 +0,0 @@
// +build linux freebsd netbsd openbsd
package runner
import "os/exec"
const (
// DefaultChromePath is the default path to the google-chrome executable if
// a variant cannot be found on $PATH.
DefaultChromePath = "/usr/bin/google-chrome"
)
// chromeNames are the Chrome executable names to search for in the path.
var chromeNames = []string{
"google-chrome",
"chromium-browser",
"chromium",
"google-chrome-beta",
"google-chrome-unstable",
}
func findChromePath() string {
for _, p := range chromeNames {
path, err := exec.LookPath(p)
if err == nil {
return path
}
}
return DefaultChromePath
}

View File

@ -1,33 +0,0 @@
// +build windows
package runner
import "os/exec"
const (
// DefaultChromePath is the default path to use for Google Chrome if the
// executable is not in %PATH%.
DefaultChromePath = `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`
// DefaultEdgeDiagnosticsAdapterPath is the default path to use for the
// Microsoft Edge Diagnostics Adapter if the executable is not in %PATH%.
DefaultEdgeDiagnosticsAdapterPath = `c:\Edge\EdgeDiagnosticsAdapter\x64\EdgeDiagnosticsAdapter.exe`
)
func findChromePath() string {
path, err := exec.LookPath(`chrome.exe`)
if err == nil {
return path
}
return DefaultChromePath
}
func findEdgePath() string {
path, err := exec.LookPath(`EdgeDiagnosticsAdapter.exe`)
if err == nil {
return path
}
return DefaultEdgeDiagnosticsAdapterPath
}

View File

@ -1,457 +0,0 @@
// Package runner provides a Chrome process runner.
package runner
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/url"
"os"
"os/exec"
"regexp"
"runtime"
"sync"
"syscall"
"github.com/chromedp/chromedp/client"
)
const (
// DefaultUserDataDirPrefix is the default user data directory prefix.
DefaultUserDataDirPrefix = "chromedp-runner.%d."
)
// Runner holds information about a running Chrome process.
type Runner struct {
opts map[string]interface{}
cmd *exec.Cmd
waiting bool
rw sync.RWMutex
}
// New creates a new Chrome process using the supplied command line options.
func New(opts ...CommandLineOption) (*Runner, error) {
var err error
cliOpts := map[string]interface{}{}
// apply opts
for _, o := range opts {
err = o(cliOpts)
if err != nil {
return nil, err
}
}
// set default Chrome options if exec-path not provided
if _, ok := cliOpts["exec-path"]; !ok {
cliOpts["exec-path"] = findChromePath()
for k, v := range map[string]interface{}{
"no-first-run": true,
"no-default-browser-check": true,
"remote-debugging-port": 9222,
} {
if _, ok := cliOpts[k]; !ok {
cliOpts[k] = v
}
}
}
// add KillProcessGroup and ForceKill if no other cmd opts provided
if _, ok := cliOpts["cmd-opts"]; !ok {
for _, o := range []CommandLineOption{KillProcessGroup, ForceKill} {
err = o(cliOpts)
if err != nil {
return nil, err
}
}
}
return &Runner{
opts: cliOpts,
}, nil
}
// cliOptRE is a regular expression to validate a chrome cli option.
var cliOptRE = regexp.MustCompile(`^[a-z0-9\-]+$`)
// buildOpts generates the command line options for Chrome.
func (r *Runner) buildOpts() []string {
var opts []string
// process options
var urlstr string
for k, v := range r.opts {
if !cliOptRE.MatchString(k) || v == nil {
continue
}
switch k {
case "exec-path", "cmd-opts", "process-opts":
continue
case "start-url":
urlstr = v.(string)
default:
switch z := v.(type) {
case bool:
if z {
opts = append(opts, "--"+k)
}
case string:
opts = append(opts, "--"+k+"="+z)
default:
opts = append(opts, "--"+k+"="+fmt.Sprintf("%v", v))
}
}
}
if urlstr == "" {
urlstr = "about:blank"
}
return append(opts, urlstr)
}
// Start starts a Chrome process using the specified context. The Chrome
// process can be terminated by closing the passed context.
func (r *Runner) Start(ctxt context.Context) error {
var err error
var ok bool
r.rw.RLock()
cmd := r.cmd
r.rw.RUnlock()
if cmd != nil {
return errors.New("already started")
}
// setup context
if ctxt == nil {
ctxt = context.Background()
}
// set user data dir, if not provided
_, ok = r.opts["user-data-dir"]
if !ok {
r.opts["user-data-dir"], err = ioutil.TempDir(
defaultUserDataTmpDir, fmt.Sprintf(DefaultUserDataDirPrefix, r.Port()),
)
if err != nil {
return err
}
}
// ensure exec-path set
execPath, ok := r.opts["exec-path"]
if !ok {
return errors.New("exec-path command line option not set, or chrome executable not found in $PATH")
}
// create cmd
r.cmd = exec.CommandContext(ctxt, execPath.(string), r.buildOpts()...)
// apply cmd opts
if cmdOpts, ok := r.opts["cmd-opts"]; ok {
for _, co := range cmdOpts.([]func(*exec.Cmd) error) {
err = co(r.cmd)
if err != nil {
return err
}
}
}
// start process
err = r.cmd.Start()
if err != nil {
return err
}
// apply process opts
if processOpts, ok := r.opts["process-opts"]; ok {
for _, po := range processOpts.([]func(*os.Process) error) {
err = po(r.cmd.Process)
if err != nil {
// TODO: do something better here, as we want to kill
// the child process, do cleanup, etc.
panic(err)
//return err
}
}
}
return nil
}
// Shutdown shuts down the Chrome process.
func (r *Runner) Shutdown(ctxt context.Context, opts ...client.Option) error {
var err error
cl := r.Client(opts...)
targets, err := cl.ListPageTargets(ctxt)
if err != nil {
return err
}
var wg sync.WaitGroup
errs := make([]error, len(targets))
for i, t := range targets {
wg.Add(1)
go func(wg *sync.WaitGroup, i int, t client.Target) {
defer wg.Done()
errs[i] = cl.CloseTarget(ctxt, t)
}(&wg, i, t)
}
wg.Wait()
for _, e := range errs {
if e != nil {
return e
}
}
// osx applications do not automatically exit when all windows (ie, tabs)
// closed, so send SIGTERM.
//
// TODO: add other behavior here for more process options on shutdown?
if runtime.GOOS == "darwin" && r.cmd != nil && r.cmd.Process != nil {
return r.cmd.Process.Signal(syscall.SIGTERM)
}
return nil
}
// Wait waits for the previously started Chrome process to terminate, returning
// any encountered error.
func (r *Runner) Wait() error {
r.rw.RLock()
waiting := r.waiting
r.rw.RUnlock()
if waiting {
return errors.New("already waiting")
}
r.rw.Lock()
r.waiting = true
r.rw.Unlock()
defer func() {
r.rw.Lock()
r.waiting = false
r.rw.Unlock()
}()
return r.cmd.Wait()
}
// Port returns the port the process was launched with.
func (r *Runner) Port() int {
var port interface{}
var ok bool
port, ok = r.opts["remote-debugging-port"]
if !ok {
port, ok = r.opts["port"]
}
if !ok {
panic("expected either remote-debugging-port or port to be specified in command line options")
}
var p int
p, ok = port.(int)
if !ok {
panic("expected port to be type int")
}
return p
}
// Client returns a Chrome Debugging Protocol client for the running Chrome
// process.
func (r *Runner) Client(opts ...client.Option) *client.Client {
return client.New(append(opts,
client.URL(fmt.Sprintf("http://localhost:%d/json", r.Port())),
)...)
}
// WatchPageTargets returns a channel that will receive new page targets as
// they are created.
func (r *Runner) WatchPageTargets(ctxt context.Context, opts ...client.Option) <-chan client.Target {
return r.Client(opts...).WatchPageTargets(ctxt)
}
// Run starts a new Chrome process runner, using the provided context and
// command line options.
func Run(ctxt context.Context, opts ...CommandLineOption) (*Runner, error) {
var err error
// create
r, err := New(opts...)
if err != nil {
return nil, err
}
// start
err = r.Start(ctxt)
if err != nil {
return nil, err
}
return r, nil
}
// CommandLineOption is a Chrome command line option.
//
// see: http://peter.sh/experiments/chromium-command-line-switches/
type CommandLineOption func(map[string]interface{}) error
// Flag is a generic Chrome command line option to pass a name=value flag to
// Chrome.
func Flag(name string, value interface{}) CommandLineOption {
return func(m map[string]interface{}) error {
m[name] = value
return nil
}
}
// Path sets the path to the Chrome executable and sets default run options for
// Chrome. This will also set the remote debugging port to 9222, and disable
// the first run / default browser check.
//
// Note: use ExecPath if you do not want to set other options.
func Path(path string) CommandLineOption {
return func(m map[string]interface{}) error {
m["exec-path"] = path
m["no-first-run"] = true
m["no-default-browser-check"] = true
m["remote-debugging-port"] = 9222
return nil
}
}
// HeadlessPathPort is the Chrome command line option to set the default
// settings for running the headless_shell executable. If path is empty, then
// an attempt will be made to find headless_shell on the path.
func HeadlessPathPort(path string, port int) CommandLineOption {
if path == "" {
path, _ = exec.LookPath("headless_shell")
}
return func(m map[string]interface{}) error {
m["exec-path"] = path
m["remote-debugging-port"] = port
m["headless"] = true
return nil
}
}
// ExecPath is a Chrome command line option to set the exec path.
func ExecPath(path string) CommandLineOption {
return Flag("exec-path", path)
}
// Port is the Chrome command line option to set the remote debugging port.
func Port(port int) CommandLineOption {
return Flag("remote-debugging-port", port)
}
// UserDataDir is the Chrome command line option to set the user data dir.
//
// Note: set this option to manually set the profile directory used by Chrome.
// When this is not set, then a default path will be created in the /tmp
// directory.
func UserDataDir(dir string) CommandLineOption {
return Flag("user-data-dir", dir)
}
// StartURL is the Chrome command line option to set the initial URL.
func StartURL(urlstr string) CommandLineOption {
return Flag("start-url", urlstr)
}
// Proxy is the Chrome command line option to set the outbound proxy.
func Proxy(proxy string) CommandLineOption {
return Flag("proxy-server", proxy)
}
// ProxyPacURL is the Chrome command line option to set the URL of a proxy PAC file.
func ProxyPacURL(pacURL url.URL) CommandLineOption {
return Flag("proxy-pac-url", pacURL.String())
}
// WindowSize is the Chrome command line option to set the initial window size.
func WindowSize(width, height int) CommandLineOption {
return Flag("window-size", fmt.Sprintf("%d,%d", width, height))
}
// UserAgent is the Chrome command line option to set the default User-Agent
// header.
func UserAgent(userAgent string) CommandLineOption {
return Flag("user-agent", userAgent)
}
// NoSandbox is the Chrome comamnd line option to disable the sandbox.
func NoSandbox(m map[string]interface{}) error {
return Flag("no-sandbox", true)(m)
}
// NoFirstRun is the Chrome comamnd line option to disable the first run
// dialog.
func NoFirstRun(m map[string]interface{}) error {
return Flag("no-first-run", true)(m)
}
// NoDefaultBrowserCheck is the Chrome comamnd line option to disable the
// default browser check.
func NoDefaultBrowserCheck(m map[string]interface{}) error {
return Flag("no-default-browser-check", true)(m)
}
// DisableGPU is the Chrome command line option to disable the GPU process.
func DisableGPU(m map[string]interface{}) error {
return Flag("disable-gpu", true)(m)
}
// CmdOpt is a Chrome command line option to modify the underlying exec.Cmd
// prior to invocation.
func CmdOpt(o func(*exec.Cmd) error) CommandLineOption {
return func(m map[string]interface{}) error {
var opts []func(*exec.Cmd) error
if e, ok := m["cmd-opts"]; ok {
opts, ok = e.([]func(*exec.Cmd) error)
if !ok {
return errors.New("cmd-opts is in invalid state")
}
}
m["cmd-opts"] = append(opts, o)
return nil
}
}
// ProcessOpt is a Chrome command line option to modify the child os.Process
// after started exec.Cmd.Start.
func ProcessOpt(o func(*os.Process) error) CommandLineOption {
return func(m map[string]interface{}) error {
var opts []func(*os.Process) error
if e, ok := m["process-opts"]; ok {
opts, ok = e.([]func(*os.Process) error)
if !ok {
return errors.New("process-opts is in invalid state")
}
}
m["process-opts"] = append(opts, o)
return nil
}
}

View File

@ -1,11 +0,0 @@
// +build darwin freebsd netbsd openbsd
package runner
// ForceKill is a Chrome command line option that forces Chrome to be killed
// when the parent is killed.
//
// Note: sets exec.Cmd.SysProcAttr.Setpgid = true (only for Linux)
func ForceKill(m map[string]interface{}) error {
return nil
}

View File

@ -1,87 +0,0 @@
// +build linux
package runner
import (
"os"
"os/exec"
"syscall"
"unsafe"
)
// ByteCount is a type byte count const.
type ByteCount uint64
// ByteCount values.
const (
Byte ByteCount = 1
Kilobyte ByteCount = 1024 * Byte
Megabyte ByteCount = 1024 * Kilobyte
Gigabyte ByteCount = 1024 * Megabyte
)
// prlimit invokes the system's prlimit call. Copied from Go source tree.
//
// Note: this needs either the CAP_SYS_RESOURCE capability, or the invoking
// process needs to have the same functional user and group as the pid being
// modified.
//
// see: man 2 prlimit
func prlimit(pid int, res int, newv, old *syscall.Rlimit) error {
_, _, err := syscall.RawSyscall6(syscall.SYS_PRLIMIT64, uintptr(pid), uintptr(res), uintptr(unsafe.Pointer(newv)), uintptr(unsafe.Pointer(old)), 0, 0)
if err != 0 {
return err
}
return nil
}
// Rlimit is a Chrome command line option to set the soft rlimit value for res
// on a running Chrome process.
//
// Note: uses Linux prlimit system call, and is invoked after the child process
// has been started.
//
// see: man 2 prlimit
func Rlimit(res int, cur, max uint64) CommandLineOption {
return ProcessOpt(func(p *os.Process) error {
return prlimit(p.Pid, syscall.RLIMIT_AS, &syscall.Rlimit{
Cur: cur,
Max: max,
}, nil)
})
}
// LimitMemory is a Chrome command line option to set the soft memory limit for
// a running Chrome process.
//
// Note: uses Linux prlimit system call, and is invoked after the child
// process has been started.
func LimitMemory(mem ByteCount) CommandLineOption {
return Rlimit(syscall.RLIMIT_AS, uint64(mem), uint64(mem))
}
// LimitCoreDump is a Chrome command line option to set the soft core dump
// limit for a running Chrome process.
//
// Note: uses Linux prlimit system call, and is invoked after the child
// process has been started.
func LimitCoreDump(sz ByteCount) CommandLineOption {
return Rlimit(syscall.RLIMIT_CORE, uint64(sz), uint64(sz))
}
// ForceKill is a Chrome command line option that forces Chrome to be killed
// when the parent is killed.
//
// Note: sets exec.Cmd.SysProcAttr.Setpgid = true (only for Linux)
func ForceKill(m map[string]interface{}) error {
return CmdOpt(func(c *exec.Cmd) error {
if c.SysProcAttr == nil {
c.SysProcAttr = new(syscall.SysProcAttr)
}
c.SysProcAttr.Pdeathsig = syscall.SIGKILL
return nil
})(m)
}

View File

@ -1,31 +0,0 @@
// +build linux darwin freebsd netbsd openbsd
package runner
import (
"os/exec"
"syscall"
)
var (
// DefaultUserDataTmpDir is the default directory path for created user
// data directories.
defaultUserDataTmpDir = "/tmp"
)
// KillProcessGroup is a Chrome command line option that will instruct the
// invoked child Chrome process to terminate when the parent process (ie, the
// Go application) dies.
//
// Note: sets exec.Cmd.SysProcAttr.Setpgid = true and does nothing on Windows.
func KillProcessGroup(m map[string]interface{}) error {
return CmdOpt(func(c *exec.Cmd) error {
if c.SysProcAttr == nil {
c.SysProcAttr = new(syscall.SysProcAttr)
}
c.SysProcAttr.Setpgid = true
return nil
})(m)
}

View File

@ -1,44 +0,0 @@
// +build windows
package runner
import "os"
var (
defaultUserDataTmpDir = os.Getenv("USERPROFILE") + `\AppData\Local`
)
// KillProcessGroup is a Chrome command line option that will instruct the
// invoked child Chrome process to terminate when the parent process (ie, the
// Go application) dies.
//
// Note: sets exec.Cmd.SysProcAttr.Setpgid = true and does nothing on Windows.
func KillProcessGroup(m map[string]interface{}) error {
return nil
}
// ForceKill is a Chrome command line option that forces Chrome to be killed
// when the parent is killed.
//
// Note: sets exec.Cmd.SysProcAttr.Setpgid = true (only for Linux)
func ForceKill(m map[string]interface{}) error {
return nil
}
// EdgeDiagnosticsAdapterWithPath is a command line option to specify using the
// Microsoft Edge Diagnostics adapter at the specified path.
func EdgeDiagnosticsAdapterWithPathAndPort(path string, port int) CommandLineOption {
return func(m map[string]interface{}) error {
m["exec-path"] = path
m["port"] = port
return nil
}
}
// EdgeDiagnosticsAdapter is a command line option to specify using the
// Microsoft Edge Diagnostics adapter found on the path.
//
// If the
func EdgeDiagnosticsAdapter() CommandLineOption {
return EdgeDiagnosticsAdapterWithPathAndPort(findEdgePath(), 9222)
}

180
sel.go
View File

@ -5,7 +5,6 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/dom"
@ -26,9 +25,9 @@ tagname
type Selector struct {
sel interface{}
exp int
by func(context.Context, *TargetHandler, *cdp.Node) ([]cdp.NodeID, error)
wait func(context.Context, *TargetHandler, *cdp.Node, ...cdp.NodeID) ([]*cdp.Node, error)
after func(context.Context, *TargetHandler, ...*cdp.Node) error
by func(context.Context, *Target, *cdp.Node) ([]cdp.NodeID, error)
wait func(context.Context, *Target, *cdp.Frame, ...cdp.NodeID) ([]*cdp.Node, error)
after func(context.Context, *Target, ...*cdp.Node) error
}
// Query is an action to query for document nodes match the specified sel and
@ -56,21 +55,17 @@ func Query(sel interface{}, opts ...QueryOption) Action {
}
// Do satisfies the Action interface.
func (s *Selector) Do(ctxt context.Context, h cdp.Executor) error {
th, ok := h.(*TargetHandler)
func (s *Selector) Do(ctx context.Context, h cdp.Executor) error {
th, ok := h.(*Target)
if !ok {
return ErrInvalidHandler
}
// TODO: fix this
ctxt, cancel := context.WithTimeout(ctxt, 100*time.Second)
defer cancel()
var err error
select {
case err = <-s.run(ctxt, th):
case <-ctxt.Done():
err = ctxt.Err()
case err = <-s.run(ctx, th):
case <-ctx.Done():
err = ctx.Err()
}
return err
@ -79,54 +74,35 @@ func (s *Selector) Do(ctxt context.Context, h cdp.Executor) error {
// run runs the selector action, starting over if the original returned nodes
// are invalidated prior to finishing the selector's by, wait, check, and after
// funcs.
func (s *Selector) run(ctxt context.Context, h *TargetHandler) chan error {
ch := make(chan error)
func (s *Selector) run(ctx context.Context, h *Target) chan error {
ch := make(chan error, 1)
h.waitQueue <- func(cur *cdp.Frame) bool {
cur.RLock()
root := cur.Root
cur.RUnlock()
go func() {
defer close(ch)
if root == nil {
// not ready?
return false
}
for {
root, err := h.GetRoot(ctxt)
if err != nil {
select {
case <-ctxt.Done():
ch <- ctxt.Err()
return
default:
continue
}
}
select {
default:
ids, err := s.by(ctxt, h, root)
if err == nil && len(ids) >= s.exp {
nodes, err := s.wait(ctxt, h, root, ids...)
if err == nil {
if s.after == nil {
return
}
err = s.after(ctxt, h, nodes...)
if err != nil {
ch <- err
}
return
}
}
time.Sleep(DefaultCheckDuration)
case <-root.Invalidated:
continue
case <-ctxt.Done():
ch <- ctxt.Err()
return
ids, err := s.by(ctx, h, root)
if err != nil || len(ids) < s.exp {
return false
}
nodes, err := s.wait(ctx, h, cur, ids...)
// if nodes==nil, we're not yet ready
if nodes == nil || err != nil {
return false
}
if s.after != nil {
if err := s.after(ctx, h, nodes...); err != nil {
ch <- err
}
}
}()
close(ch)
return true
}
return ch
}
@ -139,20 +115,10 @@ func (s *Selector) selAsString() string {
return fmt.Sprintf("%s", s.sel)
}
// selAsInt forces sel into a int.
/*func (s *Selector) selAsInt() int {
sel, ok := s.sel.(int)
if !ok {
panic("selector must be int")
}
return sel
}*/
// QueryAfter is an action that will match the specified sel using the supplied
// query options, and after the visibility conditions of the query have been
// met, will execute f.
func QueryAfter(sel interface{}, f func(context.Context, *TargetHandler, ...*cdp.Node) error, opts ...QueryOption) Action {
func QueryAfter(sel interface{}, f func(context.Context, *Target, ...*cdp.Node) error, opts ...QueryOption) Action {
return Query(sel, append(opts, After(f))...)
}
@ -160,7 +126,7 @@ func QueryAfter(sel interface{}, f func(context.Context, *TargetHandler, ...*cdp
type QueryOption func(*Selector)
// ByFunc is a query option to set the func used to select elements.
func ByFunc(f func(context.Context, *TargetHandler, *cdp.Node) ([]cdp.NodeID, error)) QueryOption {
func ByFunc(f func(context.Context, *Target, *cdp.Node) ([]cdp.NodeID, error)) QueryOption {
return func(s *Selector) {
s.by = f
}
@ -169,8 +135,8 @@ func ByFunc(f func(context.Context, *TargetHandler, *cdp.Node) ([]cdp.NodeID, er
// ByQuery is a query option to select a single element using
// DOM.querySelector.
func ByQuery(s *Selector) {
ByFunc(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) ([]cdp.NodeID, error) {
nodeID, err := dom.QuerySelector(n.NodeID, s.selAsString()).Do(ctxt, h)
ByFunc(func(ctx context.Context, h *Target, n *cdp.Node) ([]cdp.NodeID, error) {
nodeID, err := dom.QuerySelector(n.NodeID, s.selAsString()).Do(ctx, h)
if err != nil {
return nil, err
}
@ -185,8 +151,8 @@ func ByQuery(s *Selector) {
// ByQueryAll is a query option to select elements by DOM.querySelectorAll.
func ByQueryAll(s *Selector) {
ByFunc(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) ([]cdp.NodeID, error) {
return dom.QuerySelectorAll(n.NodeID, s.selAsString()).Do(ctxt, h)
ByFunc(func(ctx context.Context, h *Target, n *cdp.Node) ([]cdp.NodeID, error) {
return dom.QuerySelectorAll(n.NodeID, s.selAsString()).Do(ctx, h)
})(s)
}
@ -199,8 +165,8 @@ func ByID(s *Selector) {
// BySearch is a query option via DOM.performSearch (works with both CSS and
// XPath queries).
func BySearch(s *Selector) {
ByFunc(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) ([]cdp.NodeID, error) {
id, count, err := dom.PerformSearch(s.selAsString()).Do(ctxt, h)
ByFunc(func(ctx context.Context, h *Target, n *cdp.Node) ([]cdp.NodeID, error) {
id, count, err := dom.PerformSearch(s.selAsString()).Do(ctx, h)
if err != nil {
return nil, err
}
@ -209,7 +175,7 @@ func BySearch(s *Selector) {
return []cdp.NodeID{}, nil
}
nodes, err := dom.GetSearchResults(id, 0, count).Do(ctxt, h)
nodes, err := dom.GetSearchResults(id, 0, count).Do(ctx, h)
if err != nil {
return nil, err
}
@ -225,9 +191,9 @@ func ByNodeID(s *Selector) {
panic("ByNodeID can only work on []cdp.NodeID")
}
ByFunc(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) ([]cdp.NodeID, error) {
ByFunc(func(ctx context.Context, h *Target, n *cdp.Node) ([]cdp.NodeID, error) {
for _, id := range ids {
err := dom.RequestChildNodes(id).WithPierce(true).Do(ctxt, h)
err := dom.RequestChildNodes(id).WithPierce(true).Do(ctx, h)
if err != nil {
return nil, err
}
@ -238,38 +204,28 @@ func ByNodeID(s *Selector) {
}
// waitReady waits for the specified nodes to be ready.
func (s *Selector) waitReady(check func(context.Context, *TargetHandler, *cdp.Node) error) func(context.Context, *TargetHandler, *cdp.Node, ...cdp.NodeID) ([]*cdp.Node, error) {
return func(ctxt context.Context, h *TargetHandler, n *cdp.Node, ids ...cdp.NodeID) ([]*cdp.Node, error) {
f, err := h.WaitFrame(ctxt, cdp.EmptyFrameID)
if err != nil {
return nil, err
}
wg := new(sync.WaitGroup)
func (s *Selector) waitReady(check func(context.Context, *Target, *cdp.Node) error) func(context.Context, *Target, *cdp.Frame, ...cdp.NodeID) ([]*cdp.Node, error) {
return func(ctx context.Context, h *Target, cur *cdp.Frame, ids ...cdp.NodeID) ([]*cdp.Node, error) {
nodes := make([]*cdp.Node, len(ids))
errs := make([]error, len(ids))
cur.RLock()
for i, id := range ids {
wg.Add(1)
go func(i int, id cdp.NodeID) {
defer wg.Done()
nodes[i], errs[i] = h.WaitNode(ctxt, f, id)
}(i, id)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
nodes[i] = cur.Nodes[id]
if nodes[i] == nil {
cur.RUnlock()
// not yet ready
return nil, nil
}
}
cur.RUnlock()
if check != nil {
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, n := range nodes {
wg.Add(1)
go func(i int, n *cdp.Node) {
defer wg.Done()
errs[i] = check(ctxt, h, n)
errs[i] = check(ctx, h, n)
}(i, n)
}
wg.Wait()
@ -286,7 +242,7 @@ func (s *Selector) waitReady(check func(context.Context, *TargetHandler, *cdp.No
}
// WaitFunc is a query option to set a custom wait func.
func WaitFunc(wait func(context.Context, *TargetHandler, *cdp.Node, ...cdp.NodeID) ([]*cdp.Node, error)) QueryOption {
func WaitFunc(wait func(context.Context, *Target, *cdp.Frame, ...cdp.NodeID) ([]*cdp.Node, error)) QueryOption {
return func(s *Selector) {
s.wait = wait
}
@ -299,9 +255,9 @@ func NodeReady(s *Selector) {
// NodeVisible is a query option to wait until the element is visible.
func NodeVisible(s *Selector) {
WaitFunc(s.waitReady(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) error {
WaitFunc(s.waitReady(func(ctx context.Context, h *Target, n *cdp.Node) error {
// check box model
_, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctxt, h)
_, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctx, h)
if err != nil {
if isCouldNotComputeBoxModelError(err) {
return ErrNotVisible
@ -312,7 +268,7 @@ func NodeVisible(s *Selector) {
// check offsetParent
var res bool
err = EvaluateAsDevTools(fmt.Sprintf(visibleJS, n.FullXPath()), &res).Do(ctxt, h)
err = EvaluateAsDevTools(fmt.Sprintf(visibleJS, n.FullXPath()), &res).Do(ctx, h)
if err != nil {
return err
}
@ -325,9 +281,9 @@ func NodeVisible(s *Selector) {
// NodeNotVisible is a query option to wait until the element is not visible.
func NodeNotVisible(s *Selector) {
WaitFunc(s.waitReady(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) error {
WaitFunc(s.waitReady(func(ctx context.Context, h *Target, n *cdp.Node) error {
// check box model
_, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctxt, h)
_, err := dom.GetBoxModel().WithNodeID(n.NodeID).Do(ctx, h)
if err != nil {
if isCouldNotComputeBoxModelError(err) {
return nil
@ -338,7 +294,7 @@ func NodeNotVisible(s *Selector) {
// check offsetParent
var res bool
err = EvaluateAsDevTools(fmt.Sprintf(visibleJS, n.FullXPath()), &res).Do(ctxt, h)
err = EvaluateAsDevTools(fmt.Sprintf(visibleJS, n.FullXPath()), &res).Do(ctx, h)
if err != nil {
return err
}
@ -351,7 +307,7 @@ func NodeNotVisible(s *Selector) {
// NodeEnabled is a query option to wait until the element is enabled.
func NodeEnabled(s *Selector) {
WaitFunc(s.waitReady(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) error {
WaitFunc(s.waitReady(func(ctx context.Context, h *Target, n *cdp.Node) error {
n.RLock()
defer n.RUnlock()
@ -367,7 +323,7 @@ func NodeEnabled(s *Selector) {
// NodeSelected is a query option to wait until the element is selected.
func NodeSelected(s *Selector) {
WaitFunc(s.waitReady(func(ctxt context.Context, h *TargetHandler, n *cdp.Node) error {
WaitFunc(s.waitReady(func(ctx context.Context, h *Target, n *cdp.Node) error {
n.RLock()
defer n.RUnlock()
@ -381,11 +337,11 @@ func NodeSelected(s *Selector) {
}))(s)
}
// NodeNotPresent is a query option to wait until no elements match are
// present matching the selector.
// NodeNotPresent is a query option to wait until no elements are present
// matching the selector.
func NodeNotPresent(s *Selector) {
s.exp = 0
WaitFunc(func(ctxt context.Context, h *TargetHandler, n *cdp.Node, ids ...cdp.NodeID) ([]*cdp.Node, error) {
WaitFunc(func(ctx context.Context, h *Target, cur *cdp.Frame, ids ...cdp.NodeID) ([]*cdp.Node, error) {
if len(ids) != 0 {
return nil, ErrHasResults
}
@ -403,7 +359,7 @@ func AtLeast(n int) QueryOption {
// After is a query option to set a func that will be executed after the wait
// has succeeded.
func After(f func(context.Context, *TargetHandler, ...*cdp.Node) error) QueryOption {
func After(f func(context.Context, *Target, ...*cdp.Node) error) QueryOption {
return func(s *Selector) {
s.after = f
}

View File

@ -9,26 +9,21 @@ import (
func TestWaitReady(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
var nodeIDs []cdp.NodeID
err := c.Run(defaultContext, NodeIDs("#input2", &nodeIDs, ByID))
if err != nil {
if err := Run(ctx, NodeIDs("#input2", &nodeIDs, ByID)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodeIDs) != 1 {
t.Errorf("expected to have exactly 1 node id: got %d", len(nodeIDs))
}
err = c.Run(defaultContext, WaitReady("#input2", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value(nodeIDs, &value, ByNodeID))
if err != nil {
if err := Run(ctx,
WaitReady("#input2", ByID),
Value(nodeIDs, &value, ByNodeID),
); err != nil {
t.Fatalf("got error: %v", err)
}
}
@ -36,26 +31,21 @@ func TestWaitReady(t *testing.T) {
func TestWaitVisible(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
var nodeIDs []cdp.NodeID
err := c.Run(defaultContext, NodeIDs("#input2", &nodeIDs, ByID))
if err != nil {
if err := Run(ctx, NodeIDs("#input2", &nodeIDs, ByID)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodeIDs) != 1 {
t.Errorf("expected to have exactly 1 node id: got %d", len(nodeIDs))
}
err = c.Run(defaultContext, WaitVisible("#input2", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value(nodeIDs, &value, ByNodeID))
if err != nil {
if err := Run(ctx,
WaitVisible("#input2", ByID),
Value(nodeIDs, &value, ByNodeID),
); err != nil {
t.Fatalf("got error: %v", err)
}
}
@ -63,31 +53,22 @@ func TestWaitVisible(t *testing.T) {
func TestWaitNotVisible(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
var nodeIDs []cdp.NodeID
err := c.Run(defaultContext, NodeIDs("#input2", &nodeIDs, ByID))
if err != nil {
if err := Run(ctx, NodeIDs("#input2", &nodeIDs, ByID)); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodeIDs) != 1 {
t.Errorf("expected to have exactly 1 node id: got %d", len(nodeIDs))
}
err = c.Run(defaultContext, Click("#button2", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, WaitNotVisible("#input2", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value(nodeIDs, &value, ByNodeID))
if err != nil {
if err := Run(ctx,
Click("#button2", ByID),
WaitNotVisible("#input2", ByID),
Value(nodeIDs, &value, ByNodeID),
); err != nil {
t.Fatalf("got error: %v", err)
}
}
@ -95,46 +76,35 @@ func TestWaitNotVisible(t *testing.T) {
func TestWaitEnabled(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
var attr string
var ok bool
err := c.Run(defaultContext, AttributeValue("#select1", "disabled", &attr, &ok, ByID))
if err != nil {
if err := Run(ctx, AttributeValue("#select1", "disabled", &attr, &ok, ByID)); err != nil {
t.Fatalf("got error: %v", err)
}
if !ok {
t.Fatal("expected element to be disabled")
}
err = c.Run(defaultContext, Click("#button3", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, WaitEnabled("#select1", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, AttributeValue("#select1", "disabled", &attr, &ok, ByID))
if err != nil {
if err := Run(ctx,
Click("#button3", ByID),
WaitEnabled("#select1", ByID),
AttributeValue("#select1", "disabled", &attr, &ok, ByID),
); err != nil {
t.Fatalf("got error: %v", err)
}
if ok {
t.Fatal("expected element to be enabled")
}
err = c.Run(defaultContext, SetAttributeValue(`//*[@id="select1"]/option[1]`, "selected", "true"))
if err != nil {
t.Fatalf("got error: %v", err)
}
var value string
err = c.Run(defaultContext, Value("#select1", &value, ByID))
if err != nil {
if err := Run(ctx,
SetAttributeValue(`//*[@id="select1"]/option[1]`, "selected", "true"),
Value("#select1", &value, ByID),
); err != nil {
t.Fatalf("got error: %v", err)
}
if value != "foo" {
t.Fatalf("expected value to be foo, got: %s", value)
}
@ -143,43 +113,32 @@ func TestWaitEnabled(t *testing.T) {
func TestWaitSelected(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
err := c.Run(defaultContext, Click("#button3", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, WaitEnabled("#select1", ByID))
if err != nil {
if err := Run(ctx,
Click("#button3", ByID),
WaitEnabled("#select1", ByID),
); err != nil {
t.Fatalf("got error: %v", err)
}
var attr string
ok := false
err = c.Run(defaultContext, AttributeValue(`//*[@id="select1"]/option[1]`, "selected", &attr, &ok))
if err != nil {
if err := Run(ctx, AttributeValue(`//*[@id="select1"]/option[1]`, "selected", &attr, &ok)); err != nil {
t.Fatalf("got error: %v", err)
}
if ok {
t.Fatal("expected element to be not selected")
}
err = c.Run(defaultContext, SetAttributeValue(`//*[@id="select1"]/option[1]`, "selected", "true"))
if err != nil {
if err := Run(ctx,
SetAttributeValue(`//*[@id="select1"]/option[1]`, "selected", "true"),
WaitSelected(`//*[@id="select1"]/option[1]`),
AttributeValue(`//*[@id="select1"]/option[1]`, "selected", &attr, nil),
); err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, WaitSelected(`//*[@id="select1"]/option[1]`))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, AttributeValue(`//*[@id="select1"]/option[1]`, "selected", &attr, nil))
if err != nil {
t.Fatalf("got error: %v", err)
}
if attr != "true" {
t.Fatal("expected element to be selected")
}
@ -188,21 +147,14 @@ func TestWaitSelected(t *testing.T) {
func TestWaitNotPresent(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
err := c.Run(defaultContext, WaitVisible("#input3", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, Click("#button4", ByID))
if err != nil {
t.Fatalf("got error: %v", err)
}
err = c.Run(defaultContext, WaitNotPresent("#input3", ByID))
if err != nil {
if err := Run(ctx,
WaitVisible("#input3", ByID),
Click("#button4", ByID),
WaitNotPresent("#input3", ByID),
); err != nil {
t.Fatalf("got error: %v", err)
}
}
@ -210,12 +162,11 @@ func TestWaitNotPresent(t *testing.T) {
func TestAtLeast(t *testing.T) {
t.Parallel()
c := testAllocate(t, "js.html")
defer c.Release()
ctx, cancel := testAllocate(t, "js.html")
defer cancel()
var nodes []*cdp.Node
err := c.Run(defaultContext, Nodes("//input", &nodes, AtLeast(3)))
if err != nil {
if err := Run(ctx, Nodes("//input", &nodes, AtLeast(3))); err != nil {
t.Fatalf("got error: %v", err)
}
if len(nodes) < 3 {

320
target.go Normal file
View File

@ -0,0 +1,320 @@
package chromedp
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"time"
"github.com/mailru/easyjson"
"github.com/chromedp/cdproto"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/inspector"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/target"
)
// Target manages a Chrome DevTools Protocol target.
type Target struct {
browser *Browser
SessionID target.SessionID
TargetID target.ID
waitQueue chan func(cur *cdp.Frame) bool
eventQueue chan *cdproto.Message
// below are the old TargetHandler fields.
// frames is the set of encountered frames.
frames map[cdp.FrameID]*cdp.Frame
// cur is the current top level frame.
cur *cdp.Frame
// logging funcs
logf, errf func(string, ...interface{})
}
func (t *Target) run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case msg := <-t.eventQueue:
if err := t.processEvent(ctx, msg); err != nil {
t.errf("could not process event: %v", err)
continue
}
default:
// prevent busy spinning. TODO: do better
time.Sleep(5 * time.Millisecond)
n := len(t.waitQueue)
if n == 0 {
continue
}
if t.cur == nil {
continue
}
for i := 0; i < n; i++ {
fn := <-t.waitQueue
if !fn(t.cur) {
// try again later.
t.waitQueue <- fn
}
}
}
}
}
func (t *Target) Execute(ctx context.Context, method string, params json.Marshaler, res json.Unmarshaler) error {
paramsMsg := emptyObj
if params != nil {
var err error
if paramsMsg, err = json.Marshal(params); err != nil {
return err
}
}
innerID := atomic.AddInt64(&t.browser.next, 1)
msg := &cdproto.Message{
ID: innerID,
Method: cdproto.MethodType(method),
Params: paramsMsg,
}
msgJSON, err := json.Marshal(msg)
if err != nil {
return err
}
sendParams := target.SendMessageToTarget(string(msgJSON)).
WithSessionID(t.SessionID)
sendParamsJSON, _ := json.Marshal(sendParams)
// We want to grab the response from the inner message.
ch := make(chan *cdproto.Message, 1)
t.browser.cmdQueue <- cmdJob{
msg: &cdproto.Message{ID: innerID},
resp: ch,
}
// The response from the outer message is uninteresting; pass a nil
// resp channel.
outerID := atomic.AddInt64(&t.browser.next, 1)
t.browser.cmdQueue <- cmdJob{
msg: &cdproto.Message{
ID: outerID,
Method: target.CommandSendMessageToTarget,
Params: sendParamsJSON,
},
}
select {
case msg := <-ch:
switch {
case msg == nil:
return ErrChannelClosed
case msg.Error != nil:
return msg.Error
case res != nil:
return json.Unmarshal(msg.Result, res)
}
case <-ctx.Done():
return ctx.Err()
}
return nil
}
// below are the old TargetHandler methods.
// processEvent processes an incoming event.
func (t *Target) processEvent(ctx context.Context, msg *cdproto.Message) error {
if msg == nil {
return ErrChannelClosed
}
// unmarshal
ev, err := cdproto.UnmarshalMessage(msg)
if err != nil {
if strings.Contains(err.Error(), "unknown command or event") {
// This is most likely an event received from an older
// Chrome which a newer cdproto doesn't have, as it is
// deprecated. Ignore that error.
// TODO: use error wrapping once Go 1.13 is released.
return nil
}
return err
}
switch ev.(type) {
case *inspector.EventDetached:
return nil
case *dom.EventDocumentUpdated:
t.documentUpdated(ctx)
return nil
}
switch msg.Method.Domain() {
case "Page":
t.pageEvent(ev)
case "DOM":
t.domEvent(ev)
}
return nil
}
// documentUpdated handles the document updated event, retrieving the document
// root for the root frame.
func (t *Target) documentUpdated(ctx context.Context) {
f := t.cur
f.Lock()
defer f.Unlock()
// invalidate nodes
if f.Root != nil {
close(f.Root.Invalidated)
}
f.Nodes = make(map[cdp.NodeID]*cdp.Node)
var err error
f.Root, err = dom.GetDocument().WithPierce(true).Do(ctx, t)
if err == context.Canceled {
return // TODO: perhaps not necessary, but useful to keep the tests less noisy
}
if err != nil {
t.errf("could not retrieve document root for %s: %v", f.ID, err)
return
}
f.Root.Invalidated = make(chan struct{})
walk(f.Nodes, f.Root)
}
// emptyObj is an empty JSON object message.
var emptyObj = easyjson.RawMessage([]byte(`{}`))
// pageEvent handles incoming page events.
func (t *Target) pageEvent(ev interface{}) {
var id cdp.FrameID
var op frameOp
switch e := ev.(type) {
case *page.EventFrameNavigated:
t.frames[e.Frame.ID] = e.Frame
t.cur = e.Frame
return
case *page.EventFrameAttached:
id, op = e.FrameID, frameAttached(e.ParentFrameID)
case *page.EventFrameDetached:
id, op = e.FrameID, frameDetached
case *page.EventFrameStartedLoading:
id, op = e.FrameID, frameStartedLoading
case *page.EventFrameStoppedLoading:
id, op = e.FrameID, frameStoppedLoading
// ignored events
case *page.EventFrameRequestedNavigation:
return
case *page.EventDomContentEventFired:
return
case *page.EventLoadEventFired:
return
case *page.EventFrameResized:
return
case *page.EventLifecycleEvent:
return
case *page.EventNavigatedWithinDocument:
return
default:
t.errf("unhandled page event %T", ev)
return
}
f := t.frames[id]
if f == nil {
// This can happen if a frame is attached or starts loading
// before it's ever navigated to. We won't have all the frame
// details just yet, but that's okay.
f = &cdp.Frame{ID: id}
t.frames[id] = f
}
f.Lock()
defer f.Unlock()
op(f)
}
// domEvent handles incoming DOM events.
func (t *Target) domEvent(ev interface{}) {
f := t.cur
var id cdp.NodeID
var op nodeOp
switch e := ev.(type) {
case *dom.EventSetChildNodes:
id, op = e.ParentID, setChildNodes(f.Nodes, e.Nodes)
case *dom.EventAttributeModified:
id, op = e.NodeID, attributeModified(e.Name, e.Value)
case *dom.EventAttributeRemoved:
id, op = e.NodeID, attributeRemoved(e.Name)
case *dom.EventInlineStyleInvalidated:
if len(e.NodeIds) == 0 {
return
}
id, op = e.NodeIds[0], inlineStyleInvalidated(e.NodeIds[1:])
case *dom.EventCharacterDataModified:
id, op = e.NodeID, characterDataModified(e.CharacterData)
case *dom.EventChildNodeCountUpdated:
id, op = e.NodeID, childNodeCountUpdated(e.ChildNodeCount)
case *dom.EventChildNodeInserted:
id, op = e.ParentNodeID, childNodeInserted(f.Nodes, e.PreviousNodeID, e.Node)
case *dom.EventChildNodeRemoved:
id, op = e.ParentNodeID, childNodeRemoved(f.Nodes, e.NodeID)
case *dom.EventShadowRootPushed:
id, op = e.HostID, shadowRootPushed(f.Nodes, e.Root)
case *dom.EventShadowRootPopped:
id, op = e.HostID, shadowRootPopped(f.Nodes, e.RootID)
case *dom.EventPseudoElementAdded:
id, op = e.ParentID, pseudoElementAdded(f.Nodes, e.PseudoElement)
case *dom.EventPseudoElementRemoved:
id, op = e.ParentID, pseudoElementRemoved(f.Nodes, e.PseudoElementID)
case *dom.EventDistributedNodesUpdated:
id, op = e.InsertionPointID, distributedNodesUpdated(e.DistributedNodes)
default:
t.errf("unhandled node event %T", ev)
return
}
n, ok := f.Nodes[id]
if !ok {
// Node ID has been invalidated. Nothing to do.
return
}
f.Lock()
defer f.Unlock()
op(n)
}
type TargetOption func(*Target)

9
testdata/iframe.html vendored Normal file
View File

@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<title>page with an iframe</title>
</head>
<body>
<iframe src="form.html"></iframe>
</body>
</html>