From 64f54049962285c3ce5fde52acf243262511b431 Mon Sep 17 00:00:00 2001 From: isaac Date: Wed, 21 Jul 2021 16:01:05 +0900 Subject: [PATCH 1/5] call event subscriptions synchronously --- .github/workflows/build_gcd_v3.yml | 19 + CHANGELOG.md | 1 + v3/chrome_target.go | 506 +++++ v3/examples/events/events.go | 170 ++ v3/examples/events/webdata/iframe.html | 27 + v3/examples/events/webdata/target.html | 44 + v3/examples/events/webdata/top.html | 20 + v3/examples/getdoc/getdoc.go | 48 + v3/examples/loadpage/loadpage.go | 75 + v3/examples/screenshot/screenshot.go | 125 ++ v3/gcd.go | 472 ++++ v3/gcd_test.go | 638 ++++++ v3/gcdapi/accessibility.go | 290 +++ v3/gcdapi/animation.go | 324 +++ v3/gcdapi/applicationcache.go | 190 ++ v3/gcdapi/audits.go | 273 +++ v3/gcdapi/backgroundservice.go | 130 ++ v3/gcdapi/browser.go | 539 +++++ v3/gcdapi/cachestorage.go | 249 +++ v3/gcdapi/cast.go | 120 ++ v3/gcdapi/console.go | 53 + v3/gcdapi/css.go | 1153 ++++++++++ v3/gcdapi/database.go | 150 ++ v3/gcdapi/debugger.go | 1141 ++++++++++ v3/gcdapi/deviceorientation.go | 51 + v3/gcdapi/dom.go | 1834 ++++++++++++++++ v3/gcdapi/domdebugger.go | 266 +++ v3/gcdapi/domsnapshot.go | 292 +++ v3/gcdapi/domstorage.go | 186 ++ v3/gcdapi/emulation.go | 618 ++++++ v3/gcdapi/fetch.go | 311 +++ v3/gcdapi/headlessexperimental.go | 103 + v3/gcdapi/heapprofiler.go | 349 +++ v3/gcdapi/indexeddb.go | 384 ++++ v3/gcdapi/input.go | 507 +++++ v3/gcdapi/inspector.go | 38 + v3/gcdapi/io.go | 140 ++ v3/gcdapi/layertree.go | 406 ++++ v3/gcdapi/log.go | 86 + v3/gcdapi/media.go | 98 + v3/gcdapi/memory.go | 244 +++ v3/gcdapi/network.go | 1651 ++++++++++++++ v3/gcdapi/overlay.go | 757 +++++++ v3/gcdapi/page.go | 1895 +++++++++++++++++ v3/gcdapi/performance.go | 108 + v3/gcdapi/performancetimeline.go | 82 + v3/gcdapi/profiler.go | 443 ++++ v3/gcdapi/runtime.go | 966 +++++++++ v3/gcdapi/schema.go | 58 + v3/gcdapi/security.go | 176 ++ v3/gcdapi/serviceworker.go | 297 +++ v3/gcdapi/storage.go | 396 ++++ v3/gcdapi/systeminfo.go | 146 ++ v3/gcdapi/target.go | 632 ++++++ v3/gcdapi/tethering.go | 65 + v3/gcdapi/tracing.go | 209 ++ v3/gcdapi/version.go | 11 + v3/gcdapi/webaudio.go | 247 +++ v3/gcdapi/webauthn.go | 320 +++ v3/gcdapigen/README.md | 46 + v3/gcdapigen/api_template.txt | 172 ++ v3/gcdapigen/build.sh | 6 + v3/gcdapigen/command.go | 71 + v3/gcdapigen/domain.go | 318 +++ v3/gcdapigen/downloader.go | 173 ++ v3/gcdapigen/event.go | 44 + v3/gcdapigen/gcdapigen.go | 191 ++ v3/gcdapigen/protocol.go | 194 ++ v3/gcdapigen/protocol.json | 1 + v3/gcdapigen/return.go | 134 ++ v3/gcdapigen/template_funcs.go | 63 + v3/gcdapigen/type.go | 90 + v3/gcdapigen/type_properties.go | 129 ++ v3/gcdapigen/utils.go | 183 ++ v3/gcdmessage/gcdmessage.go | 127 ++ v3/go.mod | 16 + v3/go.sum | 26 + v3/logger.go | 17 + v3/observer/message_observer.go | 36 + v3/testdata/console_log.html | 15 + v3/testdata/cookie.html | 19 + v3/testdata/extension/.gitignore | 1 + v3/testdata/extension/CHANGELOG.md | 18 + v3/testdata/extension/README.md | 8 + .../extension/_locales/en/messages.json | 6 + .../extension/_locales/en_GB/messages.json | 6 + v3/testdata/extension/background.js | 44 + v3/testdata/extension/images/icon128.png | Bin 0 -> 6941 bytes v3/testdata/extension/images/icon38-off.png | Bin 0 -> 3282 bytes v3/testdata/extension/images/icon38-on.png | Bin 0 -> 3957 bytes v3/testdata/extension/images/icon48.png | Bin 0 -> 2833 bytes v3/testdata/extension/manifest.json | 30 + v3/wsconn.go | 224 ++ 93 files changed, 23537 insertions(+) create mode 100644 .github/workflows/build_gcd_v3.yml create mode 100644 v3/chrome_target.go create mode 100644 v3/examples/events/events.go create mode 100644 v3/examples/events/webdata/iframe.html create mode 100644 v3/examples/events/webdata/target.html create mode 100644 v3/examples/events/webdata/top.html create mode 100644 v3/examples/getdoc/getdoc.go create mode 100644 v3/examples/loadpage/loadpage.go create mode 100644 v3/examples/screenshot/screenshot.go create mode 100644 v3/gcd.go create mode 100644 v3/gcd_test.go create mode 100644 v3/gcdapi/accessibility.go create mode 100644 v3/gcdapi/animation.go create mode 100644 v3/gcdapi/applicationcache.go create mode 100644 v3/gcdapi/audits.go create mode 100644 v3/gcdapi/backgroundservice.go create mode 100644 v3/gcdapi/browser.go create mode 100644 v3/gcdapi/cachestorage.go create mode 100644 v3/gcdapi/cast.go create mode 100644 v3/gcdapi/console.go create mode 100644 v3/gcdapi/css.go create mode 100644 v3/gcdapi/database.go create mode 100644 v3/gcdapi/debugger.go create mode 100644 v3/gcdapi/deviceorientation.go create mode 100644 v3/gcdapi/dom.go create mode 100644 v3/gcdapi/domdebugger.go create mode 100644 v3/gcdapi/domsnapshot.go create mode 100644 v3/gcdapi/domstorage.go create mode 100644 v3/gcdapi/emulation.go create mode 100644 v3/gcdapi/fetch.go create mode 100644 v3/gcdapi/headlessexperimental.go create mode 100644 v3/gcdapi/heapprofiler.go create mode 100644 v3/gcdapi/indexeddb.go create mode 100644 v3/gcdapi/input.go create mode 100644 v3/gcdapi/inspector.go create mode 100644 v3/gcdapi/io.go create mode 100644 v3/gcdapi/layertree.go create mode 100644 v3/gcdapi/log.go create mode 100644 v3/gcdapi/media.go create mode 100644 v3/gcdapi/memory.go create mode 100644 v3/gcdapi/network.go create mode 100644 v3/gcdapi/overlay.go create mode 100644 v3/gcdapi/page.go create mode 100644 v3/gcdapi/performance.go create mode 100644 v3/gcdapi/performancetimeline.go create mode 100644 v3/gcdapi/profiler.go create mode 100644 v3/gcdapi/runtime.go create mode 100644 v3/gcdapi/schema.go create mode 100644 v3/gcdapi/security.go create mode 100644 v3/gcdapi/serviceworker.go create mode 100644 v3/gcdapi/storage.go create mode 100644 v3/gcdapi/systeminfo.go create mode 100644 v3/gcdapi/target.go create mode 100644 v3/gcdapi/tethering.go create mode 100644 v3/gcdapi/tracing.go create mode 100644 v3/gcdapi/version.go create mode 100644 v3/gcdapi/webaudio.go create mode 100644 v3/gcdapi/webauthn.go create mode 100644 v3/gcdapigen/README.md create mode 100644 v3/gcdapigen/api_template.txt create mode 100644 v3/gcdapigen/build.sh create mode 100644 v3/gcdapigen/command.go create mode 100644 v3/gcdapigen/domain.go create mode 100644 v3/gcdapigen/downloader.go create mode 100644 v3/gcdapigen/event.go create mode 100644 v3/gcdapigen/gcdapigen.go create mode 100644 v3/gcdapigen/protocol.go create mode 100644 v3/gcdapigen/protocol.json create mode 100644 v3/gcdapigen/return.go create mode 100644 v3/gcdapigen/template_funcs.go create mode 100644 v3/gcdapigen/type.go create mode 100644 v3/gcdapigen/type_properties.go create mode 100644 v3/gcdapigen/utils.go create mode 100644 v3/gcdmessage/gcdmessage.go create mode 100644 v3/go.mod create mode 100644 v3/go.sum create mode 100644 v3/logger.go create mode 100644 v3/observer/message_observer.go create mode 100644 v3/testdata/console_log.html create mode 100644 v3/testdata/cookie.html create mode 100644 v3/testdata/extension/.gitignore create mode 100644 v3/testdata/extension/CHANGELOG.md create mode 100644 v3/testdata/extension/README.md create mode 100644 v3/testdata/extension/_locales/en/messages.json create mode 100644 v3/testdata/extension/_locales/en_GB/messages.json create mode 100644 v3/testdata/extension/background.js create mode 100644 v3/testdata/extension/images/icon128.png create mode 100644 v3/testdata/extension/images/icon38-off.png create mode 100644 v3/testdata/extension/images/icon38-on.png create mode 100644 v3/testdata/extension/images/icon48.png create mode 100644 v3/testdata/extension/manifest.json create mode 100644 v3/wsconn.go diff --git a/.github/workflows/build_gcd_v3.yml b/.github/workflows/build_gcd_v3.yml new file mode 100644 index 0000000..e4af145 --- /dev/null +++ b/.github/workflows/build_gcd_v3.yml @@ -0,0 +1,19 @@ +on: [push, pull_request] +name: Test +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: 1.16.x + - name: Install Packages + run: | + sudo apt-get -qq update + sudo apt-get install -y build-essential chromium-browser + - name: Checkout code + uses: actions/checkout@v2 + - name: Test + run: | + cd v3 && go test -v -race ./... \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f1bbc87..cd7cac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # Changelog (2021) +- 3.0.0 (July 21st) Breaking change of not calling dev tool events via a newly spawned go routine as it can cause messages to be delivered out of order. - 2.1.5 (June 8th) Fix race condition on error and endpoint - 2.1.4 (June 8th) Added a chrome exit handler from @camswords. - 2.1.3 (June 4th): Fix a potential blocked channel if chrome fails to start and debug port probe fails diff --git a/v3/chrome_target.go b/v3/chrome_target.go new file mode 100644 index 0000000..b07cbd9 --- /dev/null +++ b/v3/chrome_target.go @@ -0,0 +1,506 @@ +/* +The MIT License (MIT) + +Copyright (c) 2020 isaac dawson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gcd + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/wirepair/gcd/v3/observer" + + "github.com/wirepair/gcd/v3/gcdapi" + "github.com/wirepair/gcd/v3/gcdmessage" +) + +// TargetInfo defines the 'tab' or target for this chrome instance, +// can be multiple and background processes are included (not just visual tabs) +type TargetInfo struct { + Description string `json:"description"` + DevtoolsFrontendUrl string `json:"devtoolsFrontendUrl"` + FaviconUrl string `json:"faviconUrl"` + Id string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Url string `json:"url"` + WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` +} + +// responseHeader from websocket with id and method +type responseHeader struct { + Id int64 `json:"id"` + Method string `json:"method"` +} + +// devtoolsEventResponse from websocket with id and method and msg data to dispatch to subscribers +type devtoolsEventResponse struct { + Id int64 `json:"id"` + Method string `json:"method"` + Msg []byte `json:"msg"` +} + +// ChromeTarget (Tab/Process). Messages are returned to callers via non-buffered channels. Helpfully, +// the remote debugger service uses id's so we can correlate which request should match which response. +// We use a map to store the id of the request which contains a reference to a gcdmessage.Message that holds the +// reply channel for the ChromeTarget to return the response to. +// Events are handled by mapping the method name to a function which takes a target and byte output. +// For now, callers will need to unmarshall the types themselves. +type ChromeTarget struct { + ctx context.Context + sendId int64 // An Id which is atomically incremented per request. + // must be at top because of alignement and atomic usage + replyLock sync.RWMutex // lock for dispatching responses + replyDispatcher map[int64]chan *gcdmessage.Message // Replies to synch methods using a non-buffered channel + eventLock sync.RWMutex // lock for dispatching events + eventDispatcher map[string]func(*ChromeTarget, int64, []byte) // calls the function when events match the subscribed method + conn *WebSocket // the connection to the chrome debugger service for this tab/process + + // Chrome Debugger Domains + Accessibility *gcdapi.Accessibility + Animation *gcdapi.Animation + ApplicationCache *gcdapi.ApplicationCache // application cache API + Audits *gcdapi.Audits + BackgroundService *gcdapi.BackgroundService + Browser *gcdapi.Browser + CacheStorage *gcdapi.CacheStorage + Cast *gcdapi.Cast + Console *gcdapi.Console // console API + CSS *gcdapi.CSS // CSS API + Database *gcdapi.Database // Database API + Debugger *gcdapi.Debugger // JS Debugger API + DeviceOrientation *gcdapi.DeviceOrientation // Device Orientation API + DOM *gcdapi.DOM // DOM API + DOMDebugger *gcdapi.DOMDebugger // DOM Debugger API + DOMSnapshot *gcdapi.DOMSnapshot + DOMStorage *gcdapi.DOMStorage // DOM Storage API + Emulation *gcdapi.Emulation + Fetch *gcdapi.Fetch + HeadlessExperimental *gcdapi.HeadlessExperimental + HeapProfiler *gcdapi.HeapProfiler // HeapProfiler API + IndexedDB *gcdapi.IndexedDB // IndexedDB API + Input *gcdapi.Input // Why am i doing this, it's obvious what they are, I quit. + Inspector *gcdapi.Inspector + IO *gcdapi.IO + LayerTree *gcdapi.LayerTree + Log *gcdapi.Log + Memory *gcdapi.Memory + Network *gcdapi.Network + Overlay *gcdapi.Overlay + Page *gcdapi.Page + Performance *gcdapi.Performance // if stable channel you'll need to uncomment + PerformanceTimeline *gcdapi.PerformanceTimeline + Profiler *gcdapi.Profiler + Runtime *gcdapi.Runtime + Schema *gcdapi.Schema + Security *gcdapi.Security + ServiceWorker *gcdapi.ServiceWorker + Storage *gcdapi.Storage + SystemInfo *gcdapi.SystemInfo + TargetApi *gcdapi.Target // buh name collision + Tracing *gcdapi.Tracing + Tethering *gcdapi.Tethering + Media *gcdapi.Media + WebAudio *gcdapi.WebAudio + WebAuthn *gcdapi.WebAuthn + + Target *TargetInfo // The target information see, TargetInfo + sendCh chan *gcdmessage.Message // The channel used for API components to send back to use + eventCh chan *devtoolsEventResponse // The channel used for dispatching devtool events + doneCh chan struct{} // we be donez. + apiTimeout time.Duration // A customizable timeout for waiting on Chrome to respond to us + logger Log + debugger *Gcd + stopped bool // we are/have shutdown + messageObserver observer.MessageObserver +} + +// openChromeTarget creates a new Chrome Target by connecting to the service given the URL taken from initial connection. +func openChromeTarget(debugger *Gcd, target *TargetInfo, observer observer.MessageObserver) (*ChromeTarget, error) { + conn, err := wsConnection(debugger.ctx, target.WebSocketDebuggerUrl) + if err != nil { + return nil, err + } + + chromeTarget := &ChromeTarget{ + conn: conn, + ctx: debugger.ctx, + Target: target, + apiTimeout: 120 * time.Second, // default 120 seconds to wait for chrome to respond to us + sendCh: make(chan *gcdmessage.Message), + replyDispatcher: make(map[int64]chan *gcdmessage.Message), + eventDispatcher: make(map[string]func(*ChromeTarget, int64, []byte)), + eventCh: make(chan *devtoolsEventResponse), + doneCh: make(chan struct{}), + logger: debugger.logger, + debugger: debugger, + sendId: 0, + messageObserver: observer, + } + + chromeTarget.Init() + chromeTarget.listen() + return chromeTarget, nil +} + +// Init all api objects +func (c *ChromeTarget) Init() { + c.Accessibility = gcdapi.NewAccessibility(c) + c.Animation = gcdapi.NewAnimation(c) + c.ApplicationCache = gcdapi.NewApplicationCache(c) + c.Audits = gcdapi.NewAudits(c) + c.Browser = gcdapi.NewBrowser(c) + c.BackgroundService = gcdapi.NewBackgroundService(c) + c.CacheStorage = gcdapi.NewCacheStorage(c) + c.Cast = gcdapi.NewCast(c) + c.Console = gcdapi.NewConsole(c) + c.CSS = gcdapi.NewCSS(c) + c.Database = gcdapi.NewDatabase(c) + c.Debugger = gcdapi.NewDebugger(c) + c.DeviceOrientation = gcdapi.NewDeviceOrientation(c) + c.DOMDebugger = gcdapi.NewDOMDebugger(c) + c.DOM = gcdapi.NewDOM(c) + c.DOMSnapshot = gcdapi.NewDOMSnapshot(c) + c.DOMStorage = gcdapi.NewDOMStorage(c) + c.Emulation = gcdapi.NewEmulation(c) + c.HeapProfiler = gcdapi.NewHeapProfiler(c) + c.IndexedDB = gcdapi.NewIndexedDB(c) + c.Input = gcdapi.NewInput(c) + c.Inspector = gcdapi.NewInspector(c) + c.IO = gcdapi.NewIO(c) + c.LayerTree = gcdapi.NewLayerTree(c) + c.Memory = gcdapi.NewMemory(c) + c.Log = gcdapi.NewLog(c) + c.Network = gcdapi.NewNetwork(c) + c.Overlay = gcdapi.NewOverlay(c) + c.Page = gcdapi.NewPage(c) + c.Profiler = gcdapi.NewProfiler(c) + c.Runtime = gcdapi.NewRuntime(c) + c.Schema = gcdapi.NewSchema(c) + c.Security = gcdapi.NewSecurity(c) + c.SystemInfo = gcdapi.NewSystemInfo(c) + c.ServiceWorker = gcdapi.NewServiceWorker(c) + c.TargetApi = gcdapi.NewTarget(c) + c.Tracing = gcdapi.NewTracing(c) + c.Tethering = gcdapi.NewTethering(c) + c.HeadlessExperimental = gcdapi.NewHeadlessExperimental(c) + c.Performance = gcdapi.NewPerformance(c) + c.PerformanceTimeline = gcdapi.NewPerformanceTimeline(c) + c.Fetch = gcdapi.NewFetch(c) + c.Cast = gcdapi.NewCast(c) + c.Media = gcdapi.NewMedia(c) + c.WebAudio = gcdapi.NewWebAudio(c) + c.WebAuthn = gcdapi.NewWebAuthn(c) + c.BackgroundService = gcdapi.NewBackgroundService(c) +} + +// clean up this target +func (c *ChromeTarget) shutdown() { + if c.stopped == true { + return + } + c.stopped = true + + // close websocket read/write goroutines + close(c.doneCh) + + // close websocket connection + c.conn.close() +} + +// SetApiTimeout for how long we should wait before giving up gcdmessages. +// In the highly unusable (but it has occurred) event that chrome +// does not respond to one of our messages, we should be able to return +// from gcdmessage functions. +func (c *ChromeTarget) SetApiTimeout(timeout time.Duration) { + c.apiTimeout = timeout +} + +// GetApiTimeout used by gcdmessage.SendCustomReturn and gcdmessage.SendDefaultRequest +// to timeout an API call if chrome hasn't responded to us in apiTimeout +// time. +func (c *ChromeTarget) GetApiTimeout() time.Duration { + return c.apiTimeout +} + +// Subscribe Events, you must know the method name, such as Page.loadFiredEvent, and bind a function +// which takes a ChromeTarget (us) and the raw JSON byte data for that event. +func (c *ChromeTarget) Subscribe(method string, callback func(*ChromeTarget, int64, []byte)) { + c.eventLock.Lock() + c.eventDispatcher[method] = func(chromeTarget *ChromeTarget, id int64, data []byte) { + c.messageObserver.Event(method, id, data) + callback(chromeTarget, id, data) + } + c.eventLock.Unlock() +} + +// Unsubscribe the handler for no longer receiving events. +func (c *ChromeTarget) Unsubscribe(method string) { + c.eventLock.Lock() + delete(c.eventDispatcher, method) + c.eventLock.Unlock() +} + +// Listens for API components wanting to send, and recv'ing data from the Chrome Debugger Service +func (c *ChromeTarget) listen() { + go c.listenRead() + go c.listenWrite() + go c.dispatchEvents() +} + +// Listens for API components wishing to send requests to the Chrome Debugger Service +func (c *ChromeTarget) listenWrite() { + for { + select { + // send message to the browser debugger client + case msg := <-c.sendCh: + c.replyLock.Lock() + c.replyDispatcher[msg.Id] = msg.ReplyCh + c.replyLock.Unlock() + + c.logger.Println(msg.Id, " sending to chrome: ", string(msg.Data)) + err := c.conn.Send(msg.Data) + if err != nil { + c.logger.Println("error sending websocket message: ", err) + return + } + // receive done from listenRead + case <-c.doneCh: + return + case <-c.ctx.Done(): + return + } + } + +} + +// Listens for responses coming in from the Chrome Debugger Service. +func (c *ChromeTarget) listenRead() { + for { + msg, err := c.conn.Read() + if err != nil { + c.shutdown() + return + } + c.dispatchResponse(msg) + } +} + +// dispatchResponse takes in the json message and extracts +// the id or method fields to dispatch either responses or events +// to listeners. +func (c *ChromeTarget) dispatchResponse(msg []byte) { + f := &responseHeader{} + err := json.Unmarshal(msg, f) + if err != nil { + c.logger.Println("error reading response data from chrome target", err) + } + + c.replyLock.Lock() + + if r, ok := c.replyDispatcher[f.Id]; ok { + delete(c.replyDispatcher, f.Id) + c.replyLock.Unlock() + c.logDebug("dispatching response id:", f.Id) + go c.dispatchWithTimeout(r, f.Id, msg) + return + } + c.replyLock.Unlock() + + c.checkTargetDisconnected(f.Method) + + c.eventLock.RLock() + _, ok := c.eventDispatcher[f.Method] + c.eventLock.RUnlock() + + if ok { + c.eventCh <- &devtoolsEventResponse{Id: f.Id, Method: f.Method, Msg: msg} + return + } + + if c.debugger.debugEvents { + c.logger.Println("no event reciever bound: ", f.Method, " data: ", string(msg)) + } +} + +func (c *ChromeTarget) dispatchEvents() { + for { + select { + case <-c.doneCh: + return + case <-c.ctx.Done(): + return + case m := <-c.eventCh: + c.logDebug("dispatching", m.Method, "event: ") + c.eventLock.Lock() + cb, ok := c.eventDispatcher[m.Method] + c.eventLock.Unlock() + if !ok { + break + } + cb(c, m.Id, m.Msg) + } + } +} + +func (c *ChromeTarget) dispatchWithTimeout(r chan<- *gcdmessage.Message, id int64, msg []byte) { + timeout := time.NewTimer(c.GetApiTimeout()) + defer timeout.Stop() + + select { + case r <- &gcdmessage.Message{Id: id, Data: msg}: + timeout.Stop() + case <-timeout.C: + c.logDebug("timed out dispatching request id: ", id, " of msg: ", msg) + close(r) + return + } + return +} + +// check target detached/crashed +// close any replier channels that are open +// dispatch detached / crashed event as usual +func (c *ChromeTarget) checkTargetDisconnected(method string) { + switch method { + case "Inspector.targetCrashed", "Inspector.detached": + c.replyLock.Lock() + for _, replyCh := range c.replyDispatcher { + close(replyCh) + } + // empty out the dispatcher + c.replyDispatcher = make(map[int64]chan *gcdmessage.Message) + c.replyLock.Unlock() + } +} + +// Connects to the tab/process for sending/recv'ing debug events +func wsConnection(ctx context.Context, url string) (*WebSocket, error) { + client := &WebSocket{} + if err := client.Connect(ctx, url, nil); err != nil { + return nil, err + } + return client, nil +} + +// gcdmessage.ChromeTargeter interface methods + +// GetId increments the Id so we can synchronize our request/responses internally +func (c *ChromeTarget) GetId() int64 { + return atomic.AddInt64(&c.sendId, 1) +} + +// GetSendCh the channel used for API components to send back to use +func (c *ChromeTarget) GetSendCh() chan *gcdmessage.Message { + return c.sendCh +} + +// GetDoneCh channel used to signal any pending SendDefaultRequest and SendCustomReturn +// that we are exiting so we don't block goroutines from exiting. +func (c *ChromeTarget) GetDoneCh() chan struct{} { + return c.doneCh +} + +func (c *ChromeTarget) logDebug(args ...interface{}) { + if c.debugger.debug { + c.logger.Println(args...) + } +} + +// SendCustomReturn takes in a ParamRequest and gives back a response channel so the caller can decode as necessary. +func (c *ChromeTarget) SendCustomReturn(ctx context.Context, paramRequest *gcdmessage.ParamRequest) (*gcdmessage.Message, error) { + data, err := json.Marshal(paramRequest) + + if err != nil { + return nil, err + } + + c.messageObserver.Request(paramRequest.Id, paramRequest.Method, data) + + response, err := c.sendData(ctx, paramRequest.Id, data) + + c.messageObserver.Response(paramRequest.Id, paramRequest.Method, observer.DigResponseData(response), err) + return response, err +} + +// SendDefaultRequest sends a generic request that gets back a generic response, or error. This returns a ChromeResponse +// object. +func (c *ChromeTarget) SendDefaultRequest(ctx context.Context, paramRequest *gcdmessage.ParamRequest) (*gcdmessage.ChromeResponse, error) { + data, err := json.Marshal(&gcdmessage.ChromeRequest{Id: paramRequest.Id, Method: paramRequest.Method, Params: paramRequest.Params}) + + if err != nil { + return nil, err + } + + c.messageObserver.Request(paramRequest.Id, paramRequest.Method, data) + + response, err := c.sendData(ctx, paramRequest.Id, data) + + c.messageObserver.Response(paramRequest.Id, paramRequest.Method, observer.DigResponseData(response), err) + + if err != nil { + return nil, err + } + + chromeResponse := &gcdmessage.ChromeResponse{} + err = json.Unmarshal(response.Data, chromeResponse) + + if err != nil { + return nil, err + } + + return chromeResponse, nil +} + +func (c *ChromeTarget) sendData(ctx context.Context, ID int64, data []byte) (*gcdmessage.Message, error) { + recvCh := make(chan *gcdmessage.Message, 1) + + select { + case <-ctx.Done(): + return nil, &gcdmessage.ChromeCtxDoneErr{} + case c.sendCh <- &gcdmessage.Message{ReplyCh: recvCh, Id: ID, Data: data}: + case <-time.After(c.GetApiTimeout()): + return nil, &gcdmessage.ChromeApiTimeoutErr{} + case <-c.GetDoneCh(): + return nil, &gcdmessage.ChromeDoneErr{} + } + + var resp *gcdmessage.Message + select { + case <-ctx.Done(): + return nil, &gcdmessage.ChromeCtxDoneErr{} + case <-time.After(c.GetApiTimeout()): + return nil, &gcdmessage.ChromeApiTimeoutErr{} + case resp = <-recvCh: + case <-c.GetDoneCh(): + return nil, &gcdmessage.ChromeDoneErr{} + } + + if resp == nil || resp.Data == nil { + return nil, &gcdmessage.ChromeEmptyResponseErr{} + } + + return resp, nil +} diff --git a/v3/examples/events/events.go b/v3/examples/events/events.go new file mode 100644 index 0000000..175a9cb --- /dev/null +++ b/v3/examples/events/events.go @@ -0,0 +1,170 @@ +/* +Example of using DebugEvents(true) to listen to various events being emitted by +the Remote Chrome Debugger +*/ + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "time" + + "github.com/wirepair/gcd/v3" + "github.com/wirepair/gcd/v3/gcdapi" +) + +var ( + testListener net.Listener + testPath string + testDir string + testPort string + testServerAddr string +) + +var testStartupFlags = []string{"--disable-new-tab-first-run", "--no-first-run", "--disable-popup-blocking"} + +func init() { + flag.StringVar(&testPath, "chrome", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "path to chrome") + flag.StringVar(&testDir, "dir", "C:\\temp\\", "user directory") + flag.StringVar(&testPort, "port", "9222", "Debugger port") +} + +func main() { + navigatedCh := make(chan struct{}) + debugger := startGcd() + defer debugger.ExitProcess() + + ctx := context.Background() + target := startTarget(debugger) + + // subscribe to page loaded event + target.Subscribe("Page.loadEventFired", func(targ *gcd.ChromeTarget, payload []byte) { + navigatedCh <- struct{}{} + }) + + // navigate + navigateParams := &gcdapi.PageNavigateParams{Url: testServerAddr + "top.html"} + _, _, _, err := target.Page.NavigateWithParams(ctx, navigateParams) + if err != nil { + log.Fatalf("error: %s\n", err) + } + + // wait for navigation to finish + <-navigatedCh + // get the document node + doc, err := target.DOM.GetDocument(ctx, -1, true) + if err != nil { + log.Fatal(err) + } + + // request child nodes, this will come in as DOM.setChildNode events + for i := 0; i < 3; i++ { + log.Printf("requesting child nodes...") + _, err = target.DOM.RequestChildNodes(ctx, doc.NodeId, -1, true) + if err != nil { + log.Fatal(err) + } + time.Sleep(1 * time.Second) + } + + target.Subscribe("DOM.setChildNodes", func(targ *gcd.ChromeTarget, payload []byte) { + setNodes := &gcdapi.DOMSetChildNodesEvent{} + err := json.Unmarshal(payload, setNodes) + if err != nil { + log.Fatalf("error decoding setChildNodes") + } + for _, x := range setNodes.Params.Nodes { + if x.ContentDocument != nil { + checkContentDocument(targ, x) + } + for _, v := range x.Children { + if v.ContentDocument != nil { + checkContentDocument(targ, v) + } + } + } + + }) + + // wait for redirect + //time.Sleep(5 * time.Second) + + // get iframe node id + iframe, err := target.DOM.QuerySelector(ctx, doc.NodeId, "#iframe") + if err != nil { + log.Fatalf("error looking for frame") + } + + log.Printf("iframe %d\n", iframe) + time.Sleep(10 * time.Second) + debugger.ExitProcess() + os.RemoveAll(testDir) // remove new profile junk +} + +func checkContentDocument(targ *gcd.ChromeTarget, v *gcdapi.DOMNode) { + ctx := context.Background() + if v.ContentDocument != nil { + iframeDocId := v.ContentDocument.NodeId + targ.DOM.RequestChildNodes(ctx, iframeDocId, -1, true) + //nodes, _ := targ.DOM.QuerySelectorAll(iframeDocId, "div") + log.Printf("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!got iframe nodes.\n") + } else { + log.Printf("got non iframe\n") + } +} + +func startGcd() *gcd.Gcd { + testDir = testRandomDir() + testPort = testRandomPort() + testServer() // start test web server + debugger := gcd.NewChromeDebugger(gcd.WithFlags(testStartupFlags), gcd.WithEventDebugging()) + debugger.StartProcess(testPath, testDir, testPort) + return debugger +} + +func startTarget(debugger *gcd.Gcd) *gcd.ChromeTarget { + target, err := debugger.NewTab() + if err != nil { + log.Fatalf("error getting new tab: %s\n", err) + } + ctx := context.Background() + target.DOM.Enable(ctx) + target.Console.Enable(ctx) + target.Page.Enable(ctx) + //target.Debugger.Enable() + return target + +} + +func testServer() { + testListener, _ = net.Listen("tcp", ":0") + _, testServerPort, _ := net.SplitHostPort(testListener.Addr().String()) + testServerAddr = fmt.Sprintf("http://localhost:%s/", testServerPort) + go http.Serve(testListener, http.FileServer(http.Dir("webdata/"))) +} + +func testRandomPort() string { + l, err := net.Listen("tcp", ":0") + if err != nil { + log.Fatal(err) + } + _, randPort, _ := net.SplitHostPort(l.Addr().String()) + l.Close() + return randPort +} + +func testRandomDir() string { + dir, err := ioutil.TempDir(testDir, "autogcd") + if err != nil { + log.Fatalf("error getting temp dir: %s\n", err) + } + return dir +} diff --git a/v3/examples/events/webdata/iframe.html b/v3/examples/events/webdata/iframe.html new file mode 100644 index 0000000..6af4afd --- /dev/null +++ b/v3/examples/events/webdata/iframe.html @@ -0,0 +1,27 @@ + + +document modification + + + +
+
see ya!
+
+ + + \ No newline at end of file diff --git a/v3/examples/events/webdata/target.html b/v3/examples/events/webdata/target.html new file mode 100644 index 0000000..5e5f48c --- /dev/null +++ b/v3/examples/events/webdata/target.html @@ -0,0 +1,44 @@ + + +target of redirect + + + +
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
i'm a target page
+
+ + + \ No newline at end of file diff --git a/v3/examples/events/webdata/top.html b/v3/examples/events/webdata/top.html new file mode 100644 index 0000000..266766a --- /dev/null +++ b/v3/examples/events/webdata/top.html @@ -0,0 +1,20 @@ + + +frame top + + + + +
+ + + \ No newline at end of file diff --git a/v3/examples/getdoc/getdoc.go b/v3/examples/getdoc/getdoc.go new file mode 100644 index 0000000..b7e3972 --- /dev/null +++ b/v3/examples/getdoc/getdoc.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "flag" + "log" + "runtime" + + "github.com/wirepair/gcd/v3" +) + +var path string +var dir string +var port string + +func init() { + switch runtime.GOOS { + case "windows": + flag.StringVar(&path, "chrome", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "path to chrome") + flag.StringVar(&dir, "dir", "C:\\temp\\", "user directory") + case "darwin": + flag.StringVar(&path, "chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + case "linux": + flag.StringVar(&path, "chrome", "/usr/bin/chromium-browser", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + } + + flag.StringVar(&port, "port", "9222", "Debugger port") +} + +func main() { + flag.Parse() + debugger := gcd.NewChromeDebugger() + debugger.StartProcess(path, dir, port) + defer debugger.ExitProcess() + target, err := debugger.NewTab() + if err != nil { + log.Fatalf("error getting new tab: %s\n", err) + } + dom := target.DOM + r, err := dom.GetDocument(context.Background(), -1, true) + if err != nil { + log.Fatalf("error: %s\n", err) + } + + log.Printf("NodeID: %d Node Name: %s, URL: %s\n", r.NodeId, r.NodeName, r.DocumentURL) +} diff --git a/v3/examples/loadpage/loadpage.go b/v3/examples/loadpage/loadpage.go new file mode 100644 index 0000000..4171fe0 --- /dev/null +++ b/v3/examples/loadpage/loadpage.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "flag" + "log" + "runtime" + "sync" + + "github.com/wirepair/gcd/v3" + "github.com/wirepair/gcd/v3/gcdapi" +) + +var path string +var dir string +var port string + +func init() { + switch runtime.GOOS { + case "windows": + flag.StringVar(&path, "chrome", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "path to chrome") + flag.StringVar(&dir, "dir", "C:\\temp\\", "user directory") + case "darwin": + flag.StringVar(&path, "chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + case "linux": + flag.StringVar(&path, "chrome", "/usr/bin/chromium-browser", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + } + + flag.StringVar(&port, "port", "9222", "Debugger port") +} + +func main() { + var wg sync.WaitGroup + wg.Add(1) + flag.Parse() + debugger := gcd.NewChromeDebugger() + + // start process, specify a tmp profile path so we get a fresh profiled browser + // set port 9222 as the debug port + debugger.StartProcess(path, dir, port) + defer debugger.ExitProcess() // exit when done + + ctx := context.Background() + target, err := debugger.NewTab() + if err != nil { + log.Fatalf("error opening new tab: %s\n", err) + } + + //subscribe to page load + target.Subscribe("Page.loadEventFired", func(targ *gcd.ChromeTarget, v []byte) { + doc, err := target.DOM.GetDocument(ctx, -1, true) + if err == nil { + log.Printf("%s\n", doc.DocumentURL) + } + wg.Done() // page loaded, we can exit now + // if you wanted to inspect the full response data, you could do that here + }) + + // get the Page API and enable it + if _, err := target.Page.Enable(ctx); err != nil { + log.Fatalf("error getting page: %s\n", err) + } + + navigateParams := &gcdapi.PageNavigateParams{Url: "http://www.veracode.com"} + ret, _, _, err := target.Page.NavigateWithParams(ctx, navigateParams) // navigate + if err != nil { + log.Fatalf("Error navigating: %s\n", err) + } + + log.Printf("ret: %#v\n", ret) + wg.Wait() // wait for page load + debugger.CloseTab(target) +} diff --git a/v3/examples/screenshot/screenshot.go b/v3/examples/screenshot/screenshot.go new file mode 100644 index 0000000..d19fc25 --- /dev/null +++ b/v3/examples/screenshot/screenshot.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/base64" + "flag" + "fmt" + "log" + "net/url" + "os" + "runtime" + "sync" + "time" + + "github.com/wirepair/gcd/v3" + "github.com/wirepair/gcd/v3/gcdapi" +) + +const ( + numTabs = 5 +) + +var debugger *gcd.Gcd +var wg sync.WaitGroup + +var path string +var dir string +var port string + +func init() { + switch runtime.GOOS { + case "windows": + flag.StringVar(&path, "chrome", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "path to chrome") + flag.StringVar(&dir, "dir", "C:\\temp\\", "user directory") + case "darwin": + flag.StringVar(&path, "chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + case "linux": + flag.StringVar(&path, "chrome", "/usr/bin/chromium-browser", "path to chrome") + flag.StringVar(&dir, "dir", "/tmp/", "user directory") + } + + flag.StringVar(&port, "port", "9222", "Debugger port") +} + +func main() { + var err error + urls := []string{"http://www.google.com", "http://www.veracode.com", "http://www.microsoft.com", "http://bbc.co.uk", "http://www.reddit.com/r/golang"} + ctx := context.Background() + flag.Parse() + + debugger = gcd.NewChromeDebugger() + debugger.StartProcess(path, dir, port) + defer debugger.ExitProcess() + + targets := make([]*gcd.ChromeTarget, numTabs) + + for i := 0; i < numTabs; i++ { + wg.Add(1) + targets[i], err = debugger.NewTab() + if err != nil { + log.Fatalf("error getting targets") + } + page := targets[i].Page + page.Enable(ctx) + targets[i].Subscribe("Page.loadEventFired", pageLoaded) + // navigate + navigateParams := &gcdapi.PageNavigateParams{Url: urls[i]} + _, _, _, err := page.NavigateWithParams(ctx, navigateParams) + if err != nil { + log.Fatalf("error: %s\n", err) + } + } + wg.Wait() + for i := 0; i < numTabs; i++ { + takeScreenShot(targets[i]) + } +} + +func pageLoaded(target *gcd.ChromeTarget, event []byte) { + target.Unsubscribe("Page.loadEventFired") + wg.Done() +} + +func takeScreenShot(target *gcd.ChromeTarget) { + ctx := context.Background() + dom := target.DOM + page := target.Page + doc, err := dom.GetDocument(ctx, -1, true) + if err != nil { + fmt.Printf("error getting doc: %s\n", err) + return + } + + debugger.ActivateTab(target) + time.Sleep(1 * time.Second) // give it a sec to paint + u, urlErr := url.Parse(doc.DocumentURL) + if urlErr != nil { + fmt.Printf("error parsing url: %s\n", urlErr) + return + } + + fmt.Printf("Taking screen shot of: %s\n", u.Host) + screenShotParams := &gcdapi.PageCaptureScreenshotParams{Format: "png", FromSurface: true} + img, errCap := page.CaptureScreenshotWithParams(ctx, screenShotParams) + if errCap != nil { + fmt.Printf("error getting doc: %s\n", errCap) + return + } + + imgBytes, errDecode := base64.StdEncoding.DecodeString(img) + if errDecode != nil { + fmt.Printf("error decoding image: %s\n", errDecode) + return + } + + f, errFile := os.Create(u.Host + ".png") + defer f.Close() + if errFile != nil { + fmt.Printf("error creating image file: %s\n", errFile) + return + } + f.Write(imgBytes) + debugger.CloseTab(target) +} diff --git a/v3/gcd.go b/v3/gcd.go new file mode 100644 index 0000000..81d5b93 --- /dev/null +++ b/v3/gcd.go @@ -0,0 +1,472 @@ +/* +The MIT License (MIT) + +Copyright (c) 2020 isaac dawson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package gcd + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "sync" + "time" + + "github.com/wirepair/gcd/v3/observer" + + jsoniter "github.com/json-iterator/go" + + "github.com/wirepair/gcd/v3/gcdapi" +) + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +var GCDVERSION = "v3.0.0" + +var ( + ErrNoTabAvailable = errors.New("no available tab found") +) + +// When we get an error reading the body from the debugger api endpoint +type GcdBodyReadErr struct { + Message string +} + +func (g *GcdBodyReadErr) Error() string { + return "error reading response body: " + g.Message +} + +// Failure to unmarshal the JSON response from debugger API +type GcdDecodingErr struct { + Message string +} + +func (g *GcdDecodingErr) Error() string { + return "error decoding inspectable page: " + g.Message +} + +type TerminatedHandler func(reason string) +type OnChromeExitHandler func(profileDir string, err error) + +// The Google Chrome Debugger +type Gcd struct { + processLock *sync.RWMutex // make go -race detection happy for process + + timeout time.Duration // how much time to wait for debugger port to open up + chromeProcess *os.Process + chromeCmd *exec.Cmd + terminatedHandler TerminatedHandler + onChromeExitHandler OnChromeExitHandler + port string + host string + addr string + profileDir string + deleteProfile bool + readyChErr chan error + apiEndpoint string + flags []string + env []string + chomeApiVersion string + ctx context.Context + logger Log + debugEvents bool + debug bool + messageObserver observer.MessageObserver +} + +// Give it a friendly name. +func NewChromeDebugger(opts ...func(*Gcd)) *Gcd { + c := &Gcd{processLock: &sync.RWMutex{}} + c.timeout = time.Second * 15 + c.host = "localhost" + c.readyChErr = make(chan error) + c.terminatedHandler = nil + c.onChromeExitHandler = nil + c.flags = make([]string, 0) + c.env = make([]string, 0) + c.ctx = context.Background() + c.logger = LogDiscarder{} + c.messageObserver = observer.NewIgnoreMessagesObserver() + + for _, o := range opts { + o(c) + } + return c +} + +// WithTerminationHandler Pass a handler to be notified when the chrome process exits. +func WithTerminationHandler(handler TerminatedHandler) func(*Gcd) { + return func(g *Gcd) { + g.terminatedHandler = handler + } +} + +// WithOnChromeExitHandler Pass a handler to be notified when the chrome process exits. +func WithOnChromeExitHandler(handler OnChromeExitHandler) func(*Gcd) { + return func(g *Gcd) { + g.onChromeExitHandler = handler + } +} + +// WithDebugPortTimeout for how long we should wait for debug port to become available. +func WithDebugPortTimeout(timeout time.Duration) func(*Gcd) { + return func(g *Gcd) { + g.timeout = timeout + } +} + +// WithFlags allows caller to add additional startup flags to the chrome process +func WithFlags(flags []string) func(*Gcd) { + return func(g *Gcd) { + g.flags = append(g.flags, flags...) + } +} + +// WithEnvironmentVars for the chrome process, useful for Xvfb etc. +func WithEnvironmentVars(vars []string) func(*Gcd) { + return func(g *Gcd) { + g.env = append(g.env, vars...) + } +} + +func WithDeleteProfileOnExit() func(*Gcd) { + return func(g *Gcd) { + g.deleteProfile = true + } +} + +func WithLogger(l Log) func(*Gcd) { + return func(g *Gcd) { + g.logger = l + } +} + +func WithContext(ctx context.Context) func(*Gcd) { + return func(g *Gcd) { + g.ctx = ctx + } +} + +func WithEventDebugging() func(*Gcd) { + return func(g *Gcd) { + g.debugEvents = true + } +} + +func WithInternalDebugMessages() func(*Gcd) { + return func(g *Gcd) { + g.debug = true + } +} + +func WithMessageObserver(observer observer.MessageObserver) func(*Gcd) { + return func(g *Gcd) { + g.messageObserver = observer + } +} + +// Port that the debugger is listening on +func (c *Gcd) Port() string { + return c.port +} + +// Host that the debugger is listening on +func (c *Gcd) Host() string { + return c.host +} + +// StartProcess the process +// exePath - the path to the executable +// userDir - the user directory to start from so we get a fresh profile +// port - The port to listen on. +func (c *Gcd) StartProcess(exePath, userDir, port string) error { + c.port = port + c.addr = fmt.Sprintf("%s:%s", c.host, c.port) + c.profileDir = userDir + c.apiEndpoint = fmt.Sprintf("http://%s/json", c.addr) + // profile directory + c.flags = append(c.flags, fmt.Sprintf("--user-data-dir=%s", c.profileDir)) + // debug port to use + c.flags = append(c.flags, fmt.Sprintf("--remote-debugging-port=%s", port)) + // bypass first run check + c.flags = append(c.flags, "--no-first-run") + // bypass default browser check + c.flags = append(c.flags, "--no-default-browser-check") + + c.chromeCmd = exec.Command(exePath, c.flags...) + // add custom environment variables. + c.chromeCmd.Env = os.Environ() + c.chromeCmd.Env = append(c.chromeCmd.Env, c.env...) + + return c.startProcess() +} + +// StartProcessCustom lets you pass in the exec.Cmd to use +func (c *Gcd) StartProcessCustom(cmd *exec.Cmd, userDir, port string) error { + c.port = port + c.addr = fmt.Sprintf("%s:%s", c.host, c.port) + c.profileDir = userDir + c.apiEndpoint = fmt.Sprintf("http://%s/json", c.addr) + c.chromeCmd = cmd + + return c.startProcess() +} + +// startProcess starts the process and waits for the debugger port to be ready +func (c *Gcd) startProcess() error { + go func() { + err := c.chromeCmd.Start() + if err != nil { + msg := fmt.Sprintf("failed to start chrome: %s", err) + if c.terminatedHandler != nil { + c.terminatedHandler(msg) + } else { + c.logger.Println(msg) + } + } + c.processLock.Lock() + c.chromeProcess = c.chromeCmd.Process + c.processLock.Unlock() + err = c.chromeCmd.Wait() + + if c.onChromeExitHandler != nil { + c.onChromeExitHandler(c.profileDir, err) + } + + c.removeProfileDir() + + closeMessage := "exited" + if err != nil { + closeMessage = err.Error() + } + if c.terminatedHandler != nil { + c.terminatedHandler(closeMessage) + } + }() + + go func(endpoint string) { + c.probeDebugPort(endpoint) + }(c.apiEndpoint) + err := <-c.readyChErr + + return err +} + +// ExitProcess kills the process +func (c *Gcd) ExitProcess() error { + c.processLock.Lock() + err := c.chromeProcess.Kill() + c.processLock.Unlock() + return err +} + +// PID of the started process +func (c *Gcd) PID() int { + c.processLock.Lock() + pid := c.chromeProcess.Pid + c.processLock.Unlock() + return pid +} + +// removeProfileDir if deleteProfile is true +func (c *Gcd) removeProfileDir() { + if c.deleteProfile { + time.Sleep(time.Second * 1) + if err := os.RemoveAll(c.profileDir); err != nil { + c.logger.Println("error deleting profile directory", err) + } + } +} + +// ConnectToInstance connects to a running chrome instance without starting a local process +// Host - The host destination. +// Port - The port to listen on. +func (c *Gcd) ConnectToInstance(host string, port string) error { + c.host = host + c.port = port + c.addr = fmt.Sprintf("%s:%s", c.host, c.port) + c.apiEndpoint = fmt.Sprintf("http://%s/json", c.addr) + + go func(endpoint string) { + c.probeDebugPort(endpoint) + }(c.apiEndpoint) + err := <-c.readyChErr + + return err +} + +// GetTargets primary tabs/processes to work with. Each will have their own references +// to the underlying API components (such as Page, Debugger, DOM etc). +func (c *Gcd) GetTargets() ([]*ChromeTarget, error) { + empty := make(map[string]struct{}, 0) + return c.GetNewTargets(empty) +} + +// GetNewTargets gets a list of current tabs and creates new chrome targets returning a list +// provided they weren't in the knownIds list. Note it is an error to attempt +// to create a new chrome target from one that already exists. +func (c *Gcd) GetNewTargets(knownIds map[string]struct{}) ([]*ChromeTarget, error) { + connectableTargets, err := c.getConnectableTargets() + if err != nil { + return nil, err + } + + chromeTargets := make([]*ChromeTarget, 0) + for _, connectableTarget := range connectableTargets { + if _, ok := knownIds[connectableTarget.Id]; !ok { + target, err := openChromeTarget(c, connectableTarget, c.messageObserver) + if err != nil { + return nil, err + } + chromeTargets = append(chromeTargets, target) + } + } + return chromeTargets, nil +} + +func (c *Gcd) getConnectableTargets() ([]*TargetInfo, error) { + // some times it takes a while to get results, so retry 4x + for i := 0; i < 4; i++ { + resp, err := http.Get(c.apiEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, errRead := ioutil.ReadAll(resp.Body) + if errRead != nil { + return nil, &GcdBodyReadErr{Message: errRead.Error()} + } + + targets := make([]*TargetInfo, 0) + err = json.Unmarshal(body, &targets) + if err != nil { + return nil, &GcdDecodingErr{Message: err.Error()} + } + + connectableTargets := make([]*TargetInfo, 0) + for _, v := range targets { + if v.WebSocketDebuggerUrl != "" { + connectableTargets = append(connectableTargets, v) + } + } + + if len(connectableTargets) > 0 { + return connectableTargets, nil + } + time.Sleep(time.Millisecond * 350) + } + return nil, ErrNoTabAvailable +} + +// NewTab a new empty tab, returns the chrome target. +func (c *Gcd) NewTab() (*ChromeTarget, error) { + resp, err := http.Get(c.apiEndpoint + "/new") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, errRead := ioutil.ReadAll(resp.Body) + if errRead != nil { + return nil, &GcdBodyReadErr{Message: errRead.Error()} + } + + tabTarget := &TargetInfo{} + err = json.Unmarshal(body, &tabTarget) + if err != nil { + return nil, &GcdDecodingErr{Message: err.Error()} + } + return openChromeTarget(c, tabTarget, c.messageObserver) +} + +// GetFirstTab returns the first tab created, to be called when +// first started, otherwise you will get a random tab returned. +func (c *Gcd) GetFirstTab() (*ChromeTarget, error) { + connectableTargets, err := c.getConnectableTargets() + if err != nil { + return nil, err + } + + for _, tabTarget := range connectableTargets { + if tabTarget.Type == "page" { + return openChromeTarget(c, tabTarget, c.messageObserver) + } + } + return nil, ErrNoTabAvailable +} + +// GetRevision of chrome +func (c *Gcd) GetRevision() string { + return gcdapi.CHROME_VERSION +} + +// CloseTab closes the target tab. +func (c *Gcd) CloseTab(target *ChromeTarget) error { + resp, err := http.Get(fmt.Sprintf("%s/close/%s", c.apiEndpoint, target.Target.Id)) + if err != nil { + return err + } + defer resp.Body.Close() + _, errRead := ioutil.ReadAll(resp.Body) + return errRead +} + +// ActivateTab (focus) the tab. +func (c *Gcd) ActivateTab(target *ChromeTarget) error { + resp, err := http.Get(fmt.Sprintf("%s/activate/%s", c.apiEndpoint, target.Target.Id)) + if err != nil { + return err + } + defer resp.Body.Close() + _, errRead := ioutil.ReadAll(resp.Body) + return errRead +} + +// probes the debugger report and signals when it's available. +func (c *Gcd) probeDebugPort(endpoint string) error { + ticker := time.NewTicker(time.Millisecond * 100) + timeoutTicker := time.NewTicker(c.timeout) + + defer func() { + ticker.Stop() + timeoutTicker.Stop() + }() + + for { + select { + case <-ticker.C: + resp, err := http.Get(endpoint) + if err != nil { + continue + } + defer resp.Body.Close() + c.readyChErr <- nil + case <-timeoutTicker.C: + c.readyChErr <- fmt.Errorf("Unable to contact debugger at %s after %d seconds, gave up", c.apiEndpoint, c.timeout) + } + } +} diff --git a/v3/gcd_test.go b/v3/gcd_test.go new file mode 100644 index 0000000..25f93d5 --- /dev/null +++ b/v3/gcd_test.go @@ -0,0 +1,638 @@ +package gcd + +import ( + "context" + "flag" + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/wirepair/gcd/v3/gcdapi" +) + +var ( + debugger *Gcd + testListener net.Listener + testSkipNetworkIntercept bool + testPath string + testDir string + testPort string + testServerAddr string + testCtx = context.Background() +) + +func init() { + switch runtime.GOOS { + case "windows": + flag.StringVar(&testPath, "chrome", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "path to chrome") + flag.StringVar(&testDir, "dir", "C:\\temp\\gcd\\", "user directory") + case "darwin": + flag.StringVar(&testPath, "chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "path to chrome") + flag.StringVar(&testDir, "dir", "/tmp/gcd/", "user directory") + case "linux": + flag.StringVar(&testPath, "chrome", "/usr/bin/chromium-browser", "path to chrome") + flag.StringVar(&testDir, "dir", "/tmp/gcd/", "user directory") + } + flag.StringVar(&testPort, "port", "9222", "Debugger port") + +} + +func TestMain(m *testing.M) { + flag.Parse() + testServer() + os.MkdirAll(testDir, 0755) + ret := m.Run() + testCleanUp() + os.Exit(ret) +} + +func testCleanUp() { + testListener.Close() + os.RemoveAll(testDir) +} + +func TestDeleteProfileOnExit(t *testing.T) { + if runtime.GOOS == "windows" { + //t.Skip("windows will hold on to the process handle too long") + } + + debugger := NewChromeDebugger(WithDeleteProfileOnExit(), + WithFlags([]string{"--headless"}), + ) + + profileDir := testRandomTempDir(t) + err := debugger.StartProcess(testPath, profileDir, testRandomPort(t)) + if err != nil { + t.Fatalf("error starting chrome: %s\n", err) + } + debugger.ExitProcess() + time.Sleep(3 * time.Second) + if stat, err := os.Stat(profileDir); err == nil { + t.Fatalf("error temporary profileDir still exists: %s\n", stat.Name()) + } +} + +func TestGetPages(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + + targets, err := debugger.GetTargets() + if err != nil { + t.Fatalf("error getting targets: %s\n", err) + } + if len(targets) <= 0 { + t.Fatalf("invalid number of targets, got: %d\n", len(targets)) + } + t.Logf("page: %s\n", targets[0].Target.Url) +} + +func TestEnv(t *testing.T) { + var ok bool + debugger = NewChromeDebugger(WithEnvironmentVars([]string{"hello=youze", "zoinks=scoob"}), WithFlags([]string{"--headless"})) + debugger.StartProcess(testPath, testRandomTempDir(t), testRandomPort(t)) + defer debugger.ExitProcess() + + t.Logf("%#v\n", debugger.chromeCmd.Env) + for _, v := range debugger.chromeCmd.Env { + if v == "hello=youze" { + ok = true + } + } + if !ok { + t.Fatalf("error finding our environment vars in chrome process") + } +} + +func TestProcessKilled(t *testing.T) { + doneCh := make(chan struct{}) + + terminatedHandler := func(reason string) { + t.Logf("reason: %s\n", reason) + doneCh <- struct{}{} + } + + testDefaultStartup(t, WithTerminationHandler(terminatedHandler)) + shutdown := time.NewTimer(time.Second * 4) + timeout := time.NewTimer(time.Second * 10) + for { + select { + case <-doneCh: + goto DONE + case <-shutdown.C: + debugger.ExitProcess() + case <-timeout.C: + t.Fatalf("timed out waiting for termination") + } + } +DONE: +} + +func TestTargetCrashed(t *testing.T) { + + testDefaultStartup(t) + defer debugger.ExitProcess() + + doneCh := make(chan struct{}) + go testTimeoutListener(t, doneCh, 5, "timed out waiting for crashed to be handled") + + targetCrashedFn := func(targ *ChromeTarget, _ int64, payload []byte) { + t.Logf("reason: %s\n", string(payload)) + close(doneCh) + } + + tab, err := debugger.NewTab() + if err != nil { + t.Fatalf("error creating new tab") + } + + tab.Inspector.Enable(testCtx) + tab.Subscribe("Inspector.targetCrashed", targetCrashedFn) + + navParams := &gcdapi.PageNavigateParams{Url: "chrome://crash", TransitionType: "typed"} + tab.Page.NavigateWithParams(testCtx, navParams) + <-doneCh +} + +func TestEvents(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + console := target.Console + + doneCh := make(chan struct{}, 1) + target.Subscribe("Console.messageAdded", func(target *ChromeTarget, _ int64, v []byte) { + target.Unsubscribe("Console.messageAdded") + msg := &gcdapi.ConsoleMessageAddedEvent{} + err := json.Unmarshal(v, msg) + if err != nil { + t.Fatalf("error unmarshalling event data: %v\n", err) + } + t.Logf("METHOD: %s\n", msg.Method) + eventData := msg.Params.Message + t.Logf("Got event: %v\n", eventData) + close(doneCh) + }) + + _, err = console.Enable(testCtx) + if err != nil { + t.Fatalf("error sending enable: %s\n", err) + } + + navParams := &gcdapi.PageNavigateParams{Url: testServerAddr + "console_log.html", TransitionType: "typed"} + if _, _, _, err := target.Page.NavigateWithParams(testCtx, navParams); err != nil { + t.Fatalf("error attempting to navigate: %s\n", err) + } + + go testTimeoutListener(t, doneCh, 5, "console message") + + <-doneCh +} + +func TestEvaluate(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + + doneCh := make(chan struct{}, 1) + target.Subscribe("Runtime.executionContextCreated", func(target *ChromeTarget, _ int64, v []byte) { + //target.Unsubscribe("Console.messageAdded") + msg := &gcdapi.RuntimeExecutionContextCreatedEvent{} + err := json.Unmarshal(v, msg) + if err != nil { + t.Fatalf("error unmarshalling event data: %v\n", err) + } + + if msg.Params.Context.Origin != testServerAddr[:len(testServerAddr)-1] { + return + } + scriptSource := "document.location.href" + objectGroup := "gcdtest" + awaitPromise := false + includeCommandLineAPI := true + contextId := msg.Params.Context.Id + silent := true + returnByValue := false + generatePreview := true + userGestures := true + evalParams := &gcdapi.RuntimeEvaluateParams{Expression: scriptSource, ObjectGroup: objectGroup, IncludeCommandLineAPI: includeCommandLineAPI, Silent: silent, ContextId: contextId, ReturnByValue: returnByValue, GeneratePreview: generatePreview, UserGesture: userGestures, AwaitPromise: awaitPromise} + rro, exception, err := target.Runtime.EvaluateWithParams(testCtx, evalParams) + if err != nil { + t.Fatalf("error evaluating: %s %#v\n", err, exception) + } + + if val, ok := rro.Value.(string); ok { + if val != testServerAddr { + t.Fatalf("invalid location returned expected %s got %s\n", testServerAddr, val) + } + } else { + t.Fatalf("error rro.Value was not a string") + } + close(doneCh) + }) + target.Runtime.Enable(testCtx) + + navParams := &gcdapi.PageNavigateParams{Url: testServerAddr, TransitionType: "typed"} + target.Page.NavigateWithParams(testCtx, navParams) + <-doneCh +} + +func TestSimpleReturn(t *testing.T) { + var ret bool + testDefaultStartup(t) + defer debugger.ExitProcess() + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + network := target.Network + if _, err := network.Enable(testCtx, -1, -1, -1); err != nil { + t.Fatalf("error enabling network") + } + ret, err = network.CanClearBrowserCache(testCtx) + if err != nil { + t.Fatalf("error getting response to clearing browser cache: %s\n", err) + } + if !ret { + t.Fatalf("we should have got true for can clear browser cache\n") + } +} + +// Tests that the ctx canceled doesn't cause the wsconn to get stuck in a loop in windows +func TestCtxCancel(t *testing.T) { + testDefaultStartup(t, WithEventDebugging(), WithEventDebugging()) + defer debugger.ExitProcess() + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + + network := target.Network + if _, err := network.Enable(testCtx, -1, -1, -1); err != nil { + t.Fatalf("error enabling network") + } + ctx, cancel := context.WithCancel(testCtx) + cancel() + if _, err := network.CanClearBrowserCache(ctx); err == nil { + t.Fatal(err) + } + + if _, err := network.CanClearBrowserCache(ctx); err == nil { + t.Fatal(err) + } +} + +func TestSimpleReturnWithParams(t *testing.T) { + var ret bool + testDefaultStartup(t) + defer debugger.ExitProcess() + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + network := target.Network + + networkParams := &gcdapi.NetworkEnableParams{ + MaxTotalBufferSize: -1, + MaxResourceBufferSize: -1, + } + + if _, err := network.EnableWithParams(testCtx, networkParams); err != nil { + t.Fatalf("error enabling network") + } + ret, err = network.CanClearBrowserCache(testCtx) + if err != nil { + t.Fatalf("error getting response to clearing browser cache: %s\n", err) + } + if !ret { + t.Fatalf("we should have got true for can clear browser cache\n") + } +} + +// tests getting a complex object back from inside a fired event that we subscribed to. +func TestComplexReturn(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + + doneCh := make(chan struct{}, 1) + go testTimeoutListener(t, doneCh, 7, "waiting for page load to get cookies") + target, err := debugger.NewTab() + + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + if _, err := target.Network.Enable(testCtx, -1, -1, -1); err != nil { + t.Fatalf("error enabling network %s\n", err) + } + + if _, err := target.Page.Enable(testCtx); err != nil { + t.Fatalf("error enabling page: %s\n", err) + } + + target.Subscribe("Page.loadEventFired", func(target *ChromeTarget, _ int64, payload []byte) { + var ok bool + t.Logf("page load event fired\n") + cookies, err := target.Network.GetCookies(testCtx, []string{testServerAddr}) + if err != nil { + t.Fatalf("error getting cookies!") + } + for _, v := range cookies { + t.Logf("got cookies: %#v\n", v) + if v.Name == "HEYA" { + ok = true + break + } + } + if !ok { + t.Fatalf("error finding our cookie value!") + } + close(doneCh) + }) + + navParams := &gcdapi.PageNavigateParams{Url: testServerAddr + "cookie.html", TransitionType: "typed"} + _, _, _, err = target.Page.NavigateWithParams(testCtx, navParams) + if err != nil { + t.Fatalf("error navigating to cookie page: %s\n", err) + } + + t.Logf("waiting for loadEventFired") + <-doneCh +} + +func TestConnectToInstance(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + + doneCh := make(chan struct{}) + + go testTimeoutListener(t, doneCh, 15, "timed out waiting for remote connection") + go func() { + remoteDebugger := NewChromeDebugger() + remoteDebugger.ConnectToInstance(debugger.host, debugger.port) + + _, err := remoteDebugger.NewTab() + if err != nil { + t.Fatalf("error creating new tab") + } + + targets, error := remoteDebugger.GetTargets() + if error != nil { + t.Fatalf("cannot get targets: %s \n", error) + } + if len(targets) <= 0 { + t.Fatalf("invalid number of targets, got: %d\n", len(targets)) + } + for _, target := range targets { + t.Logf("page: %s\n", target.Target.Url) + } + close(doneCh) + }() + <-doneCh +} + +func TestLocalExtension(t *testing.T) { + testExtensionStartup(t) + if err := debugger.ConnectToInstance(debugger.host, debugger.port); err != nil { + t.Fatalf("failed to connect: %s\n", err) + } + + defer debugger.ExitProcess() + + doneCh := make(chan struct{}) + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error creating new tab") + } + + if _, err := target.Page.Enable(testCtx); err != nil { + t.Fatalf("error enabling page: %s\n", err) + } + + target.Subscribe("Page.loadEventFired", func(target *ChromeTarget, _ int64, payload []byte) { + t.Logf("page load event fired\n") + close(doneCh) + }) + + if _, err := target.Network.Enable(testCtx, -1, -1, -1); err != nil { + t.Fatalf("error enabling network: %s\n", err) + } + + params := &gcdapi.PageNavigateParams{Url: "http://www.google.com"} + _, _, _, err = target.Page.NavigateWithParams(testCtx, params) + if err != nil { + t.Fatalf("error navigating: %s\n", err) + } + + go testTimeoutListener(t, doneCh, 15, "timed out waiting for remote connection") + <-doneCh +} + +func TestContextCancel(t *testing.T) { + testDefaultStartup(t, WithEventDebugging(), WithInternalDebugMessages(), WithLogger(&DebugLogger{})) + defer debugger.ExitProcess() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + defer cancel() + + target, err := debugger.GetFirstTab() + if err != nil { + t.Fatalf("error getting first tab") + } + + if _, err := target.Page.Enable(testCtx); err != nil { + t.Fatalf("error enabling page: %s\n", err) + } + + if _, err := target.Page.WaitForDebugger(ctx); err == nil { + t.Fatalf("did not get cancelation") + } +} + +func TestNetworkIntercept(t *testing.T) { + + testDefaultStartup(t, WithEventDebugging(), WithInternalDebugMessages(), WithLogger(&DebugLogger{})) + defer debugger.ExitProcess() + + doneCh := make(chan struct{}) + + target, err := debugger.NewTab() + if err != nil { + t.Fatalf("error getting new tab: %s\n", err) + } + + go testTimeoutListener(t, doneCh, 5, "timed out waiting for requestIntercepted") + ctx := context.Background() + if _, err := target.Fetch.Enable(ctx, []*gcdapi.FetchRequestPattern{ + { + UrlPattern: "*", + RequestStage: "Request", + }, + }, false); err != nil { + t.Fatalf("error enabling fetch: %s", err) + } + + target.Subscribe("Fetch.requestPaused", func(target *ChromeTarget, _ int64, payload []byte) { + close(doneCh) + + pausedEvent := &gcdapi.FetchRequestPausedEvent{} + if err := json.Unmarshal(payload, pausedEvent); err != nil { + t.Fatalf("error unmarshal: %s\n", err) + } + requestHeaders := pausedEvent.Params.Request.Headers + fetchHeaders := make([]*gcdapi.FetchHeaderEntry, 0) + for k, v := range requestHeaders { + value := "" + switch t := v.(type) { + case string: + value = t + case []string: + value = strings.Join(t, "") + } + fetchHeaders = append(fetchHeaders, &gcdapi.FetchHeaderEntry{Name: k, Value: value}) + } + + p := &gcdapi.FetchContinueRequestParams{ + RequestId: pausedEvent.Params.RequestId, + Url: pausedEvent.Params.Request.Url, + Method: pausedEvent.Method, + PostData: pausedEvent.Params.Request.PostData, + Headers: fetchHeaders, + } + target.Fetch.ContinueRequestWithParams(ctx, p) + }) + + params := &gcdapi.PageNavigateParams{Url: "http://www.example.com"} + _, _, _, err = target.Page.NavigateWithParams(testCtx, params) + if err != nil { + t.Fatalf("error navigating: %s\n", err) + } + + <-doneCh +} + +func TestGetFirstTab(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + _, err := debugger.GetFirstTab() + if err != nil { + t.Fatalf("error getting first tab: %v\n", err) + } +} + +func TestCloseTab(t *testing.T) { + testDefaultStartup(t) + defer debugger.ExitProcess() + target, err := debugger.GetFirstTab() + if err != nil { + t.Fatalf("error getting first tab: %v\n", err) + } + if err := debugger.CloseTab(target); err != nil { + t.Fatalf("error closing tab") + } +} + +type testLogger struct { + called bool +} + +func (t *testLogger) Println(msg ...interface{}) { + t.called = true +} + +func TestCustomLogger(t *testing.T) { + customLogger := &testLogger{} + testDefaultStartup(t, WithLogger(customLogger), WithEventDebugging()) + defer debugger.ExitProcess() + tab, err := debugger.GetFirstTab() + if err != nil { + t.Fatalf("error getting first tab: %v\n", err) + } + + if _, err = tab.Page.Enable(context.TODO()); err != nil { + t.Fatalf("error using custom logger") + } + + if !customLogger.called { + t.Fatalf("custom logger was not called") + } +} + +// UTILITY FUNCTIONS +func testExtensionStartup(t *testing.T) { + path, err := os.Getwd() + if err != nil { + t.Fatalf("error getting working directory: %s\n", err) + } + + sep := string(os.PathSeparator) + extensionPath := "--load-extension=" + path + sep + "testdata" + sep + "extension" + sep + debugger = NewChromeDebugger(WithFlags([]string{extensionPath, "--headless"})) + + debugger.StartProcess(testPath, testRandomTempDir(t), testRandomPort(t)) +} + +func testDefaultStartup(t *testing.T, opts ...func(*Gcd)) { + mergedOpts := make([]func(*Gcd), 0) + mergedOpts = append(mergedOpts, WithDeleteProfileOnExit()) + mergedOpts = append(mergedOpts, WithFlags([]string{"--headless"})) + mergedOpts = append(mergedOpts, opts...) + debugger = NewChromeDebugger(mergedOpts...) + + debugger.StartProcess(testPath, testRandomTempDir(t), testRandomPort(t)) +} + +func testServer() { + testListener, _ = net.Listen("tcp", ":0") + _, testServerPort, _ := net.SplitHostPort(testListener.Addr().String()) + testServerAddr = fmt.Sprintf("http://localhost:%s/", testServerPort) + go http.Serve(testListener, http.FileServer(http.Dir("testdata/"))) +} + +func testTimeoutListener(t *testing.T, closeCh chan struct{}, seconds time.Duration, message string) { + timeout := time.NewTimer(seconds * time.Second) + for { + select { + case <-closeCh: + timeout.Stop() + return + case <-timeout.C: + close(closeCh) + t.Fatalf("timed out waiting for %s", message) + return + } + } +} + +func testRandomPort(t *testing.T) string { + l, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + _, randPort, _ := net.SplitHostPort(l.Addr().String()) + l.Close() + return randPort +} + +func testRandomTempDir(t *testing.T) string { + dir, err := ioutil.TempDir(testDir, "gcd") + if err != nil { + t.Errorf("error creating temp dir: %s\n", err) + } + return dir +} diff --git a/v3/gcdapi/accessibility.go b/v3/gcdapi/accessibility.go new file mode 100644 index 0000000..b7a6f79 --- /dev/null +++ b/v3/gcdapi/accessibility.go @@ -0,0 +1,290 @@ +// AUTO-GENERATED Chrome Remote Debugger Protocol API Client +// This file contains Accessibility functionality. +// API Version: 1.3 + +package gcdapi + +import ( + "context" + + "github.com/wirepair/gcd/v3/gcdmessage" +) + +// A single source for a computed AX property. +type AccessibilityAXValueSource struct { + Type string `json:"type"` // What type of source this is. enum values: attribute, implicit, style, contents, placeholder, relatedElement + Value *AccessibilityAXValue `json:"value,omitempty"` // The value of this property source. + Attribute string `json:"attribute,omitempty"` // The name of the relevant attribute, if any. + AttributeValue *AccessibilityAXValue `json:"attributeValue,omitempty"` // The value of the relevant attribute, if any. + Superseded bool `json:"superseded,omitempty"` // Whether this source is superseded by a higher priority source. + NativeSource string `json:"nativeSource,omitempty"` // The native markup source for this value, e.g. a