diff --git a/.eslintignore b/.eslintignore index fad60f16..e00d69d9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,3 +6,4 @@ utils/testrunner/examples/ node6/* node6-test/* node6-testrunner/* +experimental/ diff --git a/experimental/README.md b/experimental/README.md new file mode 100644 index 00000000..6910c0f9 --- /dev/null +++ b/experimental/README.md @@ -0,0 +1,8 @@ +#### What's this? + +This is a **highly experimental** Puppeteer API to drive Firefox browser. + +Beware, alligators live here. + +`/juggler` - firefox-puppeteer backend +`/puppeteer-firefox` - puppeteer API for Firefox diff --git a/experimental/juggler/.cirrus.yml b/experimental/juggler/.cirrus.yml new file mode 100644 index 00000000..cb7403eb --- /dev/null +++ b/experimental/juggler/.cirrus.yml @@ -0,0 +1,38 @@ +task: + timeout_in: 120m + env: + CIRRUS_WORKING_DIR: /usr/local/src + SOURCE: /usr/local/src/ + GS_AUTH: ENCRYPTED[c4b5b0404f5bfdc1b663a1eb5b70f3187b5d470d02eec3265b06b8e0d30226781523630931c1da6db06714c0d359f71f] + PATH: /root/.cargo/bin:$PATH:$SOURCE/gcloud/google-cloud-sdk/bin + SHELL: /bin/bash + container: + dockerfile: Dockerfile + # image: ubuntu + cpu: 8 + memory: 24 + name: linux + # install_deps_script: apt-get update && apt-get install -y wget python clang llvm git curl + install_gcloud_script: ./scripts/install_gcloud.sh + check_gcloud_script: + - echo "REVISION: $(git rev-parse HEAD)" + - gsutil cp FIREFOX_SHA gs://juggler-builds/$(git rev-parse HEAD)/ + clone_firefox_script: ./scripts/fetch_firefox.sh + apply_patches_script: + - cd $SOURCE/firefox + - git config --global user.name "cirrus-ci-builder" + - git config --global user.email "aslushnikov@gmail.com" + - git am ../patches/* + - ln -s $PWD/../juggler testing/juggler + bootstrap_firefox_script: + - cd $SOURCE/firefox + - ./mach bootstrap --application-choice=browser --no-interactive + build_firefox_script: + - cd $SOURCE/firefox + - ./mach build + package_firefox_script: + - cd $SOURCE/firefox + - ./mach package + upload_build_to_gcloud_script: + - bash $SOURCE/scripts/upload_linux.sh + diff --git a/experimental/juggler/.gitignore b/experimental/juggler/.gitignore new file mode 100644 index 00000000..678332bc --- /dev/null +++ b/experimental/juggler/.gitignore @@ -0,0 +1 @@ +firefox/ diff --git a/experimental/juggler/Dockerfile b/experimental/juggler/Dockerfile new file mode 100644 index 00000000..578c7804 --- /dev/null +++ b/experimental/juggler/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:trusty + +MAINTAINER Andrey Lushnikov +ENV SHELL=/bin/bash + +# Install generic deps +RUN apt-get update -y && apt-get install -y wget python clang llvm git curl + +# Install gcc7 (mach requires 6.1+) +RUN apt-get update -y && \ + apt-get upgrade -y && \ + apt-get dist-upgrade -y && \ + apt-get install build-essential software-properties-common -y && \ + add-apt-repository ppa:ubuntu-toolchain-r/test -y && \ + apt-get update -y && \ + apt-get install gcc-7 g++-7 -y && \ + update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-7 60 --slave /usr/bin/g++ g++ /usr/bin/g++-7 && \ + update-alternatives --config gcc + +# Install llvm 3.9.0 (mach requires 3.9.0+) +RUN echo "deb http://apt.llvm.org/trusty/ llvm-toolchain-trusty-3.9 main" >> /etc/apt/sources.list && \ + echo "deb-src http://apt.llvm.org/trusty/ llvm-toolchain-trusty-3.9 main" >> /etc/apt/sources.list && \ + apt-get install clang-3.9 lldb-3.9 -y + +# Install python 3.6 (mach requires 3.5+) +RUN add-apt-repository ppa:deadsnakes/ppa -y && \ + apt-get update -y && apt-get install python3.6 -y diff --git a/experimental/juggler/FIREFOX_SHA b/experimental/juggler/FIREFOX_SHA new file mode 100644 index 00000000..fcdfbe57 --- /dev/null +++ b/experimental/juggler/FIREFOX_SHA @@ -0,0 +1 @@ +663997bb1dd09a5d93135b1707feb59024eb9db4 diff --git a/experimental/juggler/README.md b/experimental/juggler/README.md new file mode 100644 index 00000000..99dcfc03 --- /dev/null +++ b/experimental/juggler/README.md @@ -0,0 +1,77 @@ +# Juggler + +> Juggler - Firefox Automation Protocol for implementing the Puppeteer API. + +## Protocol + +See [`//src/Protocol.js`](https://github.com/GoogleChrome/puppeteer/blob/master/experimental/juggler/src/Protocol.js). + +## Building FF with Juggler + +1. Clone Juggler repository +```bash +git clone https://github.com/aslushnikov/juggler +cd juggler +``` + +2. Checkout [pinned Firefox revision](https://github.com/aslushnikov/juggler/blob/master/FIREFOX_SHA) from mozilla [github mirror](https://github.com/mozilla/gecko-dev) into `//firefox` folder. + +```bash +SOURCE=$PWD bash scripts/fetch_firefox.sh +``` + +3. Apply juggler patches to Firefox source code + +```bash +cd firefox +git am ../patches/* +ln -s $PWD/../src $PWD/testing/juggler +``` + +4. Bootstrap host environment for Firefox build and compile firefox locally + +```bash +# OPTIONAL - bootstrap host environment. +./mach bootstrap --application-choice=browser --no-interactive +# Compile browser +./mach build +``` + +## Running Firefox with Juggler + +Juggle adds a `-juggler` CLI flag that accepts a port to expose a remote protocol on. +Pass `0` to pick a random port - Juggler will print its port to STDOUT. + +``` +./mach run -- -juggler 0 +``` + +## Uploading builds to Google Storage + +Firefox builds with Juggler support are uploaded to gs://juggler-builds/ bucket. + +Project maintainers can upload builds. +To upload a build, do the following: + +1. Install [gcloud](https://cloud.google.com/sdk/install) if you haven't yet. +2. Authenticate in the cloud and select project + +```bash +gcloud auth login +gcloud config set project juggler-builds +``` + +3. Make sure **firefox is compiled**; after that, package a build for a redistribution: + +```bash +cd firefox +./mach package +``` + +4. Archive build and copy to the gbucket + +We want to ship `*.zip` archives so that it's easy to decompress them on the node-side. + +- Linux: `./scripts/upload_linux.sh` +- Mac: `./scripts/upload_mac.sh` + diff --git a/experimental/juggler/patches/0001-Introduce-nsIWebProgressListener2-onFrameLocationCha.patch b/experimental/juggler/patches/0001-Introduce-nsIWebProgressListener2-onFrameLocationCha.patch new file mode 100644 index 00000000..0ecfa24d --- /dev/null +++ b/experimental/juggler/patches/0001-Introduce-nsIWebProgressListener2-onFrameLocationCha.patch @@ -0,0 +1,160 @@ +From fb96032ad20cb0dc5fbabe52a80d13d6e6808bb8 Mon Sep 17 00:00:00 2001 +From: Andrey Lushnikov +Date: Tue, 27 Nov 2018 13:37:12 -0800 +Subject: [PATCH 1/3] Introduce nsIWebProgressListener2::onFrameLocationChange + event + +The event is fired when subframes commit navigation. +Juggler uses this event to track same-document iframe navigations. +--- + docshell/base/nsDocShell.cpp | 1 + + .../statusfilter/nsBrowserStatusFilter.cpp | 10 ++++++++ + uriloader/base/nsDocLoader.cpp | 20 ++++++++++++++++ + uriloader/base/nsDocLoader.h | 5 ++++ + uriloader/base/nsIWebProgress.idl | 7 +++++- + uriloader/base/nsIWebProgressListener2.idl | 23 +++++++++++++++++++ + 6 files changed, 65 insertions(+), 1 deletion(-) + +diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp +index ea0926732..3f738d39c 100644 +--- a/docshell/base/nsDocShell.cpp ++++ b/docshell/base/nsDocShell.cpp +@@ -1349,6 +1349,7 @@ nsDocShell::SetCurrentURI(nsIURI* aURI, nsIRequest* aRequest, + mLSHE->GetIsSubFrame(&isSubFrame); + } + ++ FireOnFrameLocationChange(this, aRequest, aURI, aLocationFlags); + if (!isSubFrame && !isRoot) { + /* + * We don't want to send OnLocationChange notifications when +diff --git a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp +index c4d04dcc4..bb9e40cca 100644 +--- a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp ++++ b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp +@@ -188,6 +188,16 @@ nsBrowserStatusFilter::OnStateChange(nsIWebProgress *aWebProgress, + return NS_OK; + } + ++ ++NS_IMETHODIMP ++nsBrowserStatusFilter::OnFrameLocationChange(nsIWebProgress *aWebProgress, ++ nsIRequest *aRequest, ++ nsIURI *aLocation, ++ uint32_t aFlags) ++{ ++ return NS_OK; ++} ++ + NS_IMETHODIMP + nsBrowserStatusFilter::OnProgressChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, +diff --git a/uriloader/base/nsDocLoader.cpp b/uriloader/base/nsDocLoader.cpp +index 524681ad8..68d3f976c 100644 +--- a/uriloader/base/nsDocLoader.cpp ++++ b/uriloader/base/nsDocLoader.cpp +@@ -1330,6 +1330,26 @@ nsDocLoader::FireOnLocationChange(nsIWebProgress* aWebProgress, + } + } + ++void ++nsDocLoader::FireOnFrameLocationChange(nsIWebProgress* aWebProgress, ++ nsIRequest* aRequest, ++ nsIURI *aUri, ++ uint32_t aFlags) ++{ ++ NOTIFY_LISTENERS(nsIWebProgress::NOTIFY_FRAME_LOCATION, ++ nsCOMPtr listener2 = ++ do_QueryReferent(info.mWeakListener); ++ if (!listener2) ++ continue; ++ listener2->OnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); ++ ); ++ ++ // Pass the notification up to the parent... ++ if (mParent) { ++ mParent->FireOnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); ++ } ++} ++ + void + nsDocLoader::FireOnStatusChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, +diff --git a/uriloader/base/nsDocLoader.h b/uriloader/base/nsDocLoader.h +index 2dc1d0cae..05f8b2877 100644 +--- a/uriloader/base/nsDocLoader.h ++++ b/uriloader/base/nsDocLoader.h +@@ -167,6 +167,11 @@ protected: + nsIURI *aUri, + uint32_t aFlags); + ++ void FireOnFrameLocationChange(nsIWebProgress* aWebProgress, ++ nsIRequest* aRequest, ++ nsIURI *aUri, ++ uint32_t aFlags); ++ + MOZ_MUST_USE bool RefreshAttempted(nsIWebProgress* aWebProgress, + nsIURI *aURI, + int32_t aDelay, +diff --git a/uriloader/base/nsIWebProgress.idl b/uriloader/base/nsIWebProgress.idl +index 0549f32e1..3078e35d7 100644 +--- a/uriloader/base/nsIWebProgress.idl ++++ b/uriloader/base/nsIWebProgress.idl +@@ -84,17 +84,22 @@ interface nsIWebProgress : nsISupports + * NOTIFY_REFRESH + * Receive onRefreshAttempted events. + * This is defined on nsIWebProgressListener2. ++ * ++ * NOTIFY_FRAME_LOCATION ++ * Receive onFrameLocationChange events. ++ * This is defined on nsIWebProgressListener2. + */ + const unsigned long NOTIFY_PROGRESS = 0x00000010; + const unsigned long NOTIFY_STATUS = 0x00000020; + const unsigned long NOTIFY_SECURITY = 0x00000040; + const unsigned long NOTIFY_LOCATION = 0x00000080; + const unsigned long NOTIFY_REFRESH = 0x00000100; ++ const unsigned long NOTIFY_FRAME_LOCATION = 0x00000200; + + /** + * This flag enables all notifications. + */ +- const unsigned long NOTIFY_ALL = 0x000001ff; ++ const unsigned long NOTIFY_ALL = 0x000002ff; + + /** + * Registers a listener to receive web progress events. +diff --git a/uriloader/base/nsIWebProgressListener2.idl b/uriloader/base/nsIWebProgressListener2.idl +index 87701f8d2..8a69e6b29 100644 +--- a/uriloader/base/nsIWebProgressListener2.idl ++++ b/uriloader/base/nsIWebProgressListener2.idl +@@ -66,4 +66,27 @@ interface nsIWebProgressListener2 : nsIWebProgressListener { + in nsIURI aRefreshURI, + in long aMillis, + in boolean aSameURI); ++ ++ /** ++ * Called when the location of the window or its subframes changes. This is not ++ * when a load is requested, but rather once it is verified that the load is ++ * going to occur in the given window. For instance, a load that starts in a ++ * window might send progress and status messages for the new site, but it ++ * will not send the onLocationChange until we are sure that we are loading ++ * this new page here. ++ * ++ * @param aWebProgress ++ * The nsIWebProgress instance that fired the notification. ++ * @param aRequest ++ * The associated nsIRequest. This may be null in some cases. ++ * @param aLocation ++ * The URI of the location that is being loaded. ++ * @param aFlags ++ * This is a value which explains the situation or the reason why ++ * the location has changed. ++ */ ++ void onFrameLocationChange(in nsIWebProgress aWebProgress, ++ in nsIRequest aRequest, ++ in nsIURI aLocation, ++ [optional] in unsigned long aFlags); + }; +-- +2.19.0.605.g01d371f741-goog + diff --git a/experimental/juggler/patches/0002-Add-Juggler-to-gecko-build-system.patch b/experimental/juggler/patches/0002-Add-Juggler-to-gecko-build-system.patch new file mode 100644 index 00000000..3e9b94d8 --- /dev/null +++ b/experimental/juggler/patches/0002-Add-Juggler-to-gecko-build-system.patch @@ -0,0 +1,24 @@ +From c6f975dbc28b902cc271f79dedc42073ab1bde7d Mon Sep 17 00:00:00 2001 +From: Andrey Lushnikov +Date: Tue, 27 Nov 2018 13:39:00 -0800 +Subject: [PATCH 2/3] Add Juggler to gecko build system + +--- + toolkit/toolkit.mozbuild | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/toolkit/toolkit.mozbuild b/toolkit/toolkit.mozbuild +index 4a0e5f172..b8abc1e72 100644 +--- a/toolkit/toolkit.mozbuild ++++ b/toolkit/toolkit.mozbuild +@@ -163,6 +163,7 @@ if CONFIG['ENABLE_MARIONETTE']: + DIRS += [ + '/testing/firefox-ui', + '/testing/marionette', ++ '/testing/juggler', + ] + + if CONFIG['ENABLE_GECKODRIVER'] and not CONFIG['MOZ_TSAN']: +-- +2.19.0.605.g01d371f741-goog + diff --git a/experimental/juggler/patches/0003-Add-Juggler-to-mozilla-packaging-script.patch b/experimental/juggler/patches/0003-Add-Juggler-to-mozilla-packaging-script.patch new file mode 100644 index 00000000..4b8880d6 --- /dev/null +++ b/experimental/juggler/patches/0003-Add-Juggler-to-mozilla-packaging-script.patch @@ -0,0 +1,43 @@ +From 1449495af094fbc5e1bb351f8387c3a341977763 Mon Sep 17 00:00:00 2001 +From: Andrey Lushnikov +Date: Thu, 29 Nov 2018 11:40:32 -0800 +Subject: [PATCH 3/3] Add Juggler to mozilla packaging script + +--- + browser/installer/allowed-dupes.mn | 6 ++++++ + browser/installer/package-manifest.in | 5 +++++ + 2 files changed, 11 insertions(+) + +diff --git a/browser/installer/allowed-dupes.mn b/browser/installer/allowed-dupes.mn +index 5685a30d9..32ba241b8 100644 +--- a/browser/installer/allowed-dupes.mn ++++ b/browser/installer/allowed-dupes.mn +@@ -154,3 +154,9 @@ browser/defaults/settings/main/example.json + # Bug 1463748 - Fork and pref-off the new error pages + browser/chrome/browser/content/browser/aboutNetError-new.xhtml + browser/chrome/browser/content/browser/aboutNetError.xhtml ++ ++# Juggler/marionette files ++chrome/juggler/content/content/floating-scrollbars.css ++browser/chrome/devtools/skin/floating-scrollbars-responsive-design.css ++chrome/juggler/content/server/stream-utils.js ++chrome/marionette/content/stream-utils.js +diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in +index 5b828784a..a5d9f9741 100644 +--- a/browser/installer/package-manifest.in ++++ b/browser/installer/package-manifest.in +@@ -338,6 +338,11 @@ + @RESPATH@/defaults/pref/marionette.js + #endif + ++@RESPATH@/chrome/juggler@JAREXT@ ++@RESPATH@/chrome/juggler.manifest ++@RESPATH@/components/juggler.manifest ++@RESPATH@/components/juggler.js ++ + @RESPATH@/components/nsAsyncShutdown.manifest + @RESPATH@/components/nsAsyncShutdown.js + +-- +2.19.0.605.g01d371f741-goog + diff --git a/experimental/juggler/scripts/fetch_firefox.sh b/experimental/juggler/scripts/fetch_firefox.sh new file mode 100755 index 00000000..b69a3d92 --- /dev/null +++ b/experimental/juggler/scripts/fetch_firefox.sh @@ -0,0 +1,18 @@ +set -e +set -x + +if [ -d $SOURCE/firefox ]; then + echo ERROR! Directory "${SOURCE}/firefox" exists. Remove it and re-run this script. + exit 1; +fi +mkdir -p $SOURCE/firefox +cd $SOURCE/firefox +git init +git remote add origin https://github.com/mozilla/gecko-dev.git +git fetch --depth 50 origin release +git reset --hard $(cat $SOURCE/FIREFOX_SHA) +if [[ $? == 0 ]]; then + echo SUCCESS +else + echo FAILED TO CHECKOUT PINNED REVISION +fi diff --git a/experimental/juggler/scripts/install_gcloud.sh b/experimental/juggler/scripts/install_gcloud.sh new file mode 100755 index 00000000..becb8b68 --- /dev/null +++ b/experimental/juggler/scripts/install_gcloud.sh @@ -0,0 +1,9 @@ +# auth +echo $GS_AUTH > $SOURCE/gsauth +# install gcloud sdk +curl https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz > /tmp/google-cloud-sdk.tar.gz +mkdir -p $SOURCE/gcloud \ + && tar -C $SOURCE/gcloud -xvf /tmp/google-cloud-sdk.tar.gz \ + && CLOUDSDK_CORE_DISABLE_PROMPTS=1 $SOURCE/gcloud/google-cloud-sdk/install.sh +gcloud auth activate-service-account --key-file=$SOURCE/gsauth +gcloud config set project juggler-builds diff --git a/experimental/juggler/scripts/upload_linux.sh b/experimental/juggler/scripts/upload_linux.sh new file mode 100755 index 00000000..6bacdca5 --- /dev/null +++ b/experimental/juggler/scripts/upload_linux.sh @@ -0,0 +1,13 @@ +set -e + +if [ -e ./FIREFOX_SHA ]; then + echo Checking Juggler root - OK +else + echo Please run this script from the Juggler root + exit 1; +fi +cd firefox/obj-x86_64-pc-linux-gnu/dist/ +zip -r firefox-linux.zip firefox +mv firefox-linux.zip ../../../ +cd - +gsutil mv firefox-linux.zip gs://juggler-builds/$(git rev-parse HEAD)/ diff --git a/experimental/juggler/scripts/upload_mac.sh b/experimental/juggler/scripts/upload_mac.sh new file mode 100755 index 00000000..1f4d0d37 --- /dev/null +++ b/experimental/juggler/scripts/upload_mac.sh @@ -0,0 +1,13 @@ +set -e + +if [ -e ./FIREFOX_SHA ]; then + echo Checking Juggler root - OK +else + echo Please run this script from the Juggler root + exit 1; +fi +cd firefox/obj-x86_64-apple-darwin17.7.0/dist/ +zip -r firefox-mac.zip firefox +mv firefox-mac.zip ../../../ +cd - +gsutil mv firefox-mac.zip gs://juggler-builds/$(git rev-parse HEAD)/ diff --git a/experimental/juggler/src/BrowserHandler.jsm b/experimental/juggler/src/BrowserHandler.jsm new file mode 100644 index 00000000..62539429 --- /dev/null +++ b/experimental/juggler/src/BrowserHandler.jsm @@ -0,0 +1,149 @@ +"use strict"; + +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {PageHandler} = ChromeUtils.import("chrome://juggler/content/PageHandler.jsm"); +const {InsecureSweepingOverride} = ChromeUtils.import("chrome://juggler/content/InsecureSweepingOverride.js"); + +class BrowserHandler { + constructor(session) { + this._session = session; + this._mainWindowPromise = waitForBrowserWindow(); + this._pageHandlers = new Map(); + this._tabsToPageHandlers = new Map(); + this._initializePages(); + this._sweepingOverride = null; + } + + async setIgnoreHTTPSErrors({enabled}) { + if (!enabled && this._sweepingOverride) { + this._sweepingOverride.unregister(); + this._sweepingOverride = null; + Services.prefs.setBoolPref('security.mixed_content.block_active_content', true); + } else if (enabled && !this._sweepingOverride) { + this._sweepingOverride = new InsecureSweepingOverride(); + this._sweepingOverride.register(); + Services.prefs.setBoolPref('security.mixed_content.block_active_content', false); + } + } + + async getInfo() { + const win = await this._mainWindowPromise; + const version = Components.classes["@mozilla.org/xre/app-info;1"] + .getService(Components.interfaces.nsIXULAppInfo) + .version; + const userAgent = Components.classes["@mozilla.org/network/protocol;1?name=http"] + .getService(Components.interfaces.nsIHttpProtocolHandler) + .userAgent; + return {version: 'Firefox/' + version, userAgent}; + } + + async _initializePages() { + const win = await this._mainWindowPromise; + const tabs = win.gBrowser.tabs; + for (const tab of win.gBrowser.tabs) + this._ensurePageHandler(tab); + win.gBrowser.tabContainer.addEventListener('TabOpen', event => { + this._ensurePageHandler(event.target); + }); + win.gBrowser.tabContainer.addEventListener('TabClose', event => { + this._removePageHandlerForTab(event.target); + }); + } + + pageForId(pageId) { + return this._pageHandlers.get(pageId) || null; + } + + _ensurePageHandler(tab) { + if (this._tabsToPageHandlers.has(tab)) + return this._tabsToPageHandlers.get(tab); + const pageHandler = new PageHandler(this._session, tab); + this._pageHandlers.set(pageHandler.id(), pageHandler); + this._tabsToPageHandlers.set(tab, pageHandler); + this._session.emitEvent('Browser.tabOpened', { + url: pageHandler.url(), + pageId: pageHandler.id() + }); + return pageHandler; + } + + _removePageHandlerForTab(tab) { + const pageHandler = this._tabsToPageHandlers.get(tab); + this._tabsToPageHandlers.delete(tab); + this._pageHandlers.delete(pageHandler.id()); + pageHandler.dispose(); + this._session.emitEvent('Browser.tabClosed', {pageId: pageHandler.id()}); + } + + async newPage() { + const win = await this._mainWindowPromise; + const tab = win.gBrowser.addTab('about:blank', { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + win.gBrowser.selectedTab = tab; + // Await navigation to about:blank + await new Promise(resolve => { + const wpl = { + onLocationChange: function(aWebProgress, aRequest, aLocation) { + tab.linkedBrowser.removeProgressListener(wpl); + resolve(); + }, + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + ]), + }; + tab.linkedBrowser.addProgressListener(wpl); + }); + const pageHandler = this._ensurePageHandler(tab); + return {pageId: pageHandler.id()}; + } + + async closePage({pageId}) { + const win = await this._mainWindowPromise; + const pageHandler = this._pageHandlers.get(pageId); + await win.gBrowser.removeTab(pageHandler.tab()); + } +} + +/** + * @return {!Promise} + */ +async function waitForBrowserWindow() { + const windowsIt = Services.wm.getEnumerator('navigator:browser'); + if (windowsIt.hasMoreElements()) + return waitForWindowLoaded(windowsIt.getNext().QueryInterface(Ci.nsIDOMChromeWindow)); + + let fulfill; + let promise = new Promise(x => fulfill = x); + + const listener = { + onOpenWindow: window => { + if (window instanceof Ci.nsIDOMChromeWindow) { + Services.wm.removeListener(listener); + fulfill(waitForWindowLoaded(window)); + } + }, + onCloseWindow: () => {} + }; + Services.wm.addListener(listener); + return promise; + + /** + * @param {!Ci.nsIDOMChromeWindow} window + * @return {!Promise} + */ + function waitForWindowLoaded(window) { + if (window.document.readyState === 'complete') + return window; + return new Promise(fulfill => { + window.addEventListener('load', function listener() { + window.removeEventListener('load', listener); + fulfill(window); + }); + }); + } +} + +var EXPORTED_SYMBOLS = ['BrowserHandler']; +this.BrowserHandler = BrowserHandler; diff --git a/experimental/juggler/src/ChromeSession.js b/experimental/juggler/src/ChromeSession.js new file mode 100644 index 00000000..562706c0 --- /dev/null +++ b/experimental/juggler/src/ChromeSession.js @@ -0,0 +1,72 @@ +const {BrowserHandler} = ChromeUtils.import("chrome://juggler/content/BrowserHandler.jsm"); +const {protocol, checkScheme} = ChromeUtils.import("chrome://juggler/content/Protocol.js"); + +class ChromeSession { + constructor(connection) { + this._connection = connection; + this._connection.onmessage = this._dispatch.bind(this); + + this._browserHandler = new BrowserHandler(this); + } + + emitEvent(eventName, params) { + const scheme = protocol.events[eventName]; + if (!scheme) + throw new Error(`ERROR: event '${eventName}' is not supported`); + const details = {}; + if (!checkScheme(scheme, params || {}, details)) + throw new Error(`ERROR: event '${eventName}' is called with ${details.errorType} parameter '${details.propertyName}': ${details.propertyValue}`); + this._connection.send({method: eventName, params}); + } + + async _dispatch(data) { + const id = data.id; + try { + const method = data.method; + const params = data.params || {}; + if (!id) + throw new Error(`ERROR: every message must have an 'id' parameter`); + if (!method) + throw new Error(`ERROR: every message must have a 'method' parameter`); + + const descriptor = protocol.methods[method]; + if (!descriptor) + throw new Error(`ERROR: method '${method}' is not supported`); + let details = {}; + if (!checkScheme(descriptor.params || {}, params, details)) + throw new Error(`ERROR: method '${method}' is called with ${details.errorType} parameter '${details.propertyName}': ${details.propertyValue}`); + + const result = await this._innerDispatch(method, params); + + details = {}; + if ((descriptor.returns || result) && !checkScheme(descriptor.returns, result, details)) + throw new Error(`ERROR: method '${method}' returned ${details.errorType} parameter '${details.propertyName}': ${details.propertyValue}`); + + this._connection.send({id, result}); + } catch (e) { + this._connection.send({id, error: { + message: e.message, + data: e.stack + }}); + } + } + + async _innerDispatch(method, params) { + const [domainName, methodName] = method.split('.'); + if (domainName === 'Browser') + return await this._browserHandler[methodName](params); + if (domainName === 'Page') { + if (!params.pageId) + throw new Error('Parameter "pageId" must be present for Page.* methods'); + const pageHandler = this._browserHandler.pageForId(params.pageId); + if (!pageHandler) + throw new Error('Failed to find page for id = ' + pageId); + return await pageHandler[methodName](params); + } + throw new Error(`INTERNAL ERROR: failed to dispatch '${method}'`); + } +} + +this.EXPORTED_SYMBOLS = ['ChromeSession']; +this.ChromeSession = ChromeSession; + diff --git a/experimental/juggler/src/Helper.js b/experimental/juggler/src/Helper.js new file mode 100644 index 00000000..47036b22 --- /dev/null +++ b/experimental/juggler/src/Helper.js @@ -0,0 +1,46 @@ +const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class Helper { + addObserver(handler, topic) { + Services.obs.addObserver(handler, topic); + return () => Services.obs.removeObserver(handler, topic); + } + + addMessageListener(receiver, eventName, handler) { + receiver.addMessageListener(eventName, handler); + return () => receiver.removeMessageListener(eventName, handler); + } + + addEventListener(receiver, eventName, handler) { + receiver.addEventListener(eventName, handler); + return () => receiver.removeEventListener(eventName, handler); + } + + on(receiver, eventName, handler) { + // The toolkit/modules/EventEmitter.jsm dispatches event name as a first argument. + // Fire event listeners without it for convenience. + const handlerWrapper = (_, ...args) => handler(...args); + receiver.on(eventName, handlerWrapper); + return () => receiver.off(eventName, handlerWrapper); + } + + addProgressListener(progress, listener, flags) { + progress.addProgressListener(listener, flags); + return () => progress.removeProgressListener(listener); + } + + removeListeners(listeners) { + for (const tearDown of listeners) + tearDown.call(null); + listeners.splice(0, listeners.length); + } + + generateId() { + return uuidGen.generateUUID().toString(); + } +} + +var EXPORTED_SYMBOLS = [ "Helper" ]; +this.Helper = Helper; + diff --git a/experimental/juggler/src/InsecureSweepingOverride.js b/experimental/juggler/src/InsecureSweepingOverride.js new file mode 100644 index 00000000..3a95e739 --- /dev/null +++ b/experimental/juggler/src/InsecureSweepingOverride.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.import("resource://gre/modules/Preferences.jsm"); +ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const registrar = + Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +const sss = Cc["@mozilla.org/ssservice;1"] + .getService(Ci.nsISiteSecurityService); + +const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level"; +const CID = Components.ID("{4b67cce0-a51c-11e6-9598-0800200c9a66}"); +const CONTRACT_ID = "@mozilla.org/security/certoverride;1"; +const DESC = "All-encompassing cert service that matches on a bitflag"; +const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist"; + +const Error = { + Untrusted: 1, + Mismatch: 2, + Time: 4, +}; + +/** + * Certificate override service that acts in an all-inclusive manner + * on TLS certificates. + * + * @throws {Components.Exception} + * If there are any problems registering the service. + */ +function InsecureSweepingOverride() { + // This needs to be an old-style class with a function constructor + // and prototype assignment because... XPCOM. Any attempt at + // modernisation will be met with cryptic error messages which will + // make your life miserable. + let service = function() {}; + service.prototype = { + hasMatchingOverride( + aHostName, aPort, aCert, aOverrideBits, aIsTemporary) { + aIsTemporary.value = false; + aOverrideBits.value = Error.Untrusted | Error.Mismatch | Error.Time; + + return true; + }, + + QueryInterface: ChromeUtils.generateQI([Ci.nsICertOverrideService]), + }; + let factory = XPCOMUtils.generateSingletonFactory(service); + + return { + register() { + // make it possible to register certificate overrides for domains + // that use HSTS or HPKP + Preferences.set(HSTS_PRELOAD_LIST_PREF, false); + Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0); + + registrar.registerFactory(CID, DESC, CONTRACT_ID, factory); + }, + + unregister() { + registrar.unregisterFactory(CID, factory); + + Preferences.reset(HSTS_PRELOAD_LIST_PREF); + Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + // through the site security service + sss.clearAll(); + sss.clearPreloads(); + }, + }; +} + +this.EXPORTED_SYMBOLS = ["InsecureSweepingOverride"]; +this.InsecureSweepingOverride = InsecureSweepingOverride; diff --git a/experimental/juggler/src/PageHandler.jsm b/experimental/juggler/src/PageHandler.jsm new file mode 100644 index 00000000..8b65c969 --- /dev/null +++ b/experimental/juggler/src/PageHandler.jsm @@ -0,0 +1,337 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; +const FRAME_SCRIPT = "chrome://juggler/content/content/ContentSession.js"; +const helper = new Helper(); + +class PageHandler { + constructor(chromeSession, tab) { + this._pageId = helper.generateId(); + this._chromeSession = chromeSession; + this._tab = tab; + this._browser = tab.linkedBrowser; + this._enabled = false; + this.QueryInterface = ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + ]); + this._browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + this._dialogs = new Map(); + + // First navigation always happens to about:blank - do not report it. + this._skipNextNavigation = true; + } + + async setViewport({viewport}) { + if (viewport) { + const {width, height} = viewport; + this._browser.style.setProperty('min-width', width + 'px'); + this._browser.style.setProperty('min-height', height + 'px'); + this._browser.style.setProperty('max-width', width + 'px'); + this._browser.style.setProperty('max-height', height + 'px'); + } else { + this._browser.style.removeProperty('min-width'); + this._browser.style.removeProperty('min-height'); + this._browser.style.removeProperty('max-width'); + this._browser.style.removeProperty('max-height'); + } + const dimensions = this._browser.getBoundingClientRect(); + await Promise.all([ + this._contentSession.send('setViewport', { + deviceScaleFactor: viewport ? viewport.deviceScaleFactor : 0, + isMobile: viewport && viewport.isMobile, + hasTouch: viewport && viewport.hasTouch, + }), + this._contentSession.send('awaitViewportDimensions', { + width: dimensions.width, + height: dimensions.height + }), + ]); + } + + _initializeDialogEvents() { + this._browser.addEventListener('DOMWillOpenModalDialog', async (event) => { + // wait for the dialog to be actually added to DOM. + await Promise.resolve(); + this._updateModalDialogs(); + }); + this._browser.addEventListener('DOMModalDialogClosed', (event) => { + this._updateModalDialogs(); + }); + this._updateModalDialogs(); + } + + _updateModalDialogs() { + const elements = new Set(this._browser.parentNode.getElementsByTagNameNS(XUL_NS, "tabmodalprompt")); + for (const dialog of this._dialogs.values()) { + if (!elements.has(dialog.element())) { + this._dialogs.delete(dialog.id()); + this._chromeSession.emitEvent('Page.dialogClosed', { + pageId: this._pageId, + dialogId: dialog.id(), + }); + } else { + elements.delete(dialog.element()); + } + } + for (const element of elements) { + const dialog = Dialog.createIfSupported(element); + if (!dialog) + continue; + this._dialogs.set(dialog.id(), dialog); + this._chromeSession.emitEvent('Page.dialogOpened', { + pageId: this._pageId, + dialogId: dialog.id(), + type: dialog.type(), + message: dialog.message(), + defaultValue: dialog.defaultValue(), + }); + } + } + + onLocationChange(aWebProgress, aRequest, aLocation) { + if (this._skipNextNavigation) { + this._skipNextNavigation = false; + return; + } + this._chromeSession.emitEvent('Browser.tabNavigated', { + pageId: this._pageId, + url: aLocation.spec + }); + } + + url() { + return this._browser.currentURI.spec; + } + + tab() { + return this._tab; + } + + id() { + return this._pageId; + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + this._initializeDialogEvents(); + this._contentSession = new ContentSession(this._chromeSession, this._browser, this._pageId); + await this._contentSession.send('enable'); + } + + async screenshot(options) { + return await this._contentSession.send('screenshot', options); + } + + async getBoundingBox(options) { + return await this._contentSession.send('getBoundingBox', options); + } + + async getContentQuads(options) { + return await this._contentSession.send('getContentQuads', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async navigate(options) { + return await this._contentSession.send('navigate', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goBack(options) { + return await this._contentSession.send('goBack', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goForward(options) { + return await this._contentSession.send('goForward', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async reload(options) { + return await this._contentSession.send('reload', options); + } + + /** + * @param {{functionText: String, frameId: String}} options + * @return {!Promise<*>} + */ + async evaluate(options) { + return await this._contentSession.send('evaluate', options); + } + + async getObjectProperties(options) { + return await this._contentSession.send('getObjectProperties', options); + } + + async addScriptToEvaluateOnNewDocument(options) { + return await this._contentSession.send('addScriptToEvaluateOnNewDocument', options); + } + + async removeScriptToEvaluateOnNewDocument(options) { + return await this._contentSession.send('removeScriptToEvaluateOnNewDocument', options); + } + + async disposeObject(options) { + return await this._contentSession.send('disposeObject', options); + } + + async dispatchKeyEvent(options) { + return await this._contentSession.send('dispatchKeyEvent', options); + } + + async dispatchMouseEvent(options) { + return await this._contentSession.send('dispatchMouseEvent', options); + } + + async insertText(options) { + return await this._contentSession.send('insertText', options); + } + + async handleDialog({dialogId, accept, promptText}) { + const dialog = this._dialogs.get(dialogId); + if (!dialog) + throw new Error('Failed to find dialog with id = ' + dialogId); + if (accept) + dialog.accept(promptText); + else + dialog.dismiss(); + } + + dispose() { + this._browser.removeProgressListener(this); + if (this._contentSession) { + this._contentSession.dispose(); + this._contentSession = null; + } + } +} + +class ContentSession { + constructor(chromeSession, browser, pageId) { + this._chromeSession = chromeSession; + this._browser = browser; + this._pageId = pageId; + this._messageId = 0; + this._pendingMessages = new Map(); + this._sessionId = helper.generateId(); + this._browser.messageManager.sendAsyncMessage('juggler:create-content-session', this._sessionId); + this._eventListeners = [ + helper.addMessageListener(this._browser.messageManager, this._sessionId, { + receiveMessage: message => this._onMessage(message) + }), + ]; + } + + dispose() { + helper.removeListeners(this._eventListeners); + for (const {resolve, reject} of this._pendingMessages.values()) + reject(new Error('Page closed.')); + this._pendingMessages.clear(); + } + + /** + * @param {string} methodName + * @param {*} params + * @return {!Promise<*>} + */ + send(methodName, params) { + const id = ++this._messageId; + const promise = new Promise((resolve, reject) => { + this._pendingMessages.set(id, {resolve, reject}); + }); + this._browser.messageManager.sendAsyncMessage(this._sessionId, {id, methodName, params}); + return promise; + } + + _onMessage({data}) { + if (data.id) { + let id = data.id; + const {resolve, reject} = this._pendingMessages.get(data.id); + this._pendingMessages.delete(data.id); + if (data.error) + reject(new Error(data.error)); + else + resolve(data.result); + } else { + const { + eventName, + params = {} + } = data; + params.pageId = this._pageId; + this._chromeSession.emitEvent(eventName, params); + } + } +} + +class Dialog { + static createIfSupported(element) { + const type = element.Dialog.args.promptType; + switch (type) { + case 'alert': + case 'prompt': + case 'confirm': + return new Dialog(element, type); + case 'confirmEx': + return new Dialog(element, 'beforeunload'); + default: + return null; + }; + } + + constructor(element, type) { + this._id = helper.generateId(); + this._type = type; + this._element = element; + } + + id() { + return this._id; + } + + message() { + return this._element.ui.infoBody.textContent; + } + + type() { + return this._type; + } + + element() { + return this._element; + } + + dismiss() { + if (this._element.ui.button1) + this._element.ui.button1.click(); + else + this._element.ui.button0.click(); + } + + defaultValue() { + return this._element.ui.loginTextbox.value; + } + + accept(promptValue) { + if (typeof promptValue === 'string' && this._type === 'prompt') + this._element.ui.loginTextbox.value = promptValue; + this._element.ui.button0.click(); + } +} + +var EXPORTED_SYMBOLS = ['PageHandler']; +this.PageHandler = PageHandler; diff --git a/experimental/juggler/src/Protocol.js b/experimental/juggler/src/Protocol.js new file mode 100644 index 00000000..c4b40fe5 --- /dev/null +++ b/experimental/juggler/src/Protocol.js @@ -0,0 +1,371 @@ +const t = { + String: x => typeof x === 'string' || typeof x === 'String', + Number: x => typeof x === 'number', + Boolean: x => typeof x === 'boolean', + Null: x => Object.is(x, null), + Enum: values => x => values.indexOf(x) !== -1, + Undefined: x => Object.is(x, undefined), + Or: (...schemes) => x => schemes.some(scheme => checkScheme(scheme, x)), + Either: (...schemes) => x => schemes.map(scheme => checkScheme(scheme, x)).reduce((acc, x) => acc + (x ? 1 : 0)) === 1, + Array: scheme => x => Array.isArray(x) && x.every(element => checkScheme(scheme, element)), + Nullable: scheme => x => Object.is(x, null) || checkScheme(scheme, x), + Optional: scheme => x => Object.is(x, undefined) || checkScheme(scheme, x), + Any: x => true, +} + +const RemoteObject = t.Either( + { + type: t.Enum(['object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint']), + subtype: t.Optional(t.Enum(['array', 'null', 'node', 'regexp', 'date', 'map', 'set', 'weakmap', 'weakset', 'error', 'proxy', 'promise', 'typedarray'])), + objectId: t.String, + }, + { + unserializableValue: t.Enum(['Infinity', '-Infinity', '-0', 'NaN']), + }, + { + value: t.Any + }, +); + +const DOMPoint = { + x: t.Number, + y: t.Number, +}; + +const DOMQuad = { + p1: DOMPoint, + p2: DOMPoint, + p3: DOMPoint, + p4: DOMPoint +}; + +const protocol = { + methods: { + 'Browser.getInfo': { + returns: { + userAgent: t.String, + version: t.String, + }, + }, + 'Browser.setIgnoreHTTPSErrors': { + params: { + enabled: t.Boolean, + }, + }, + 'Browser.newPage': { + returns: { + pageId: t.String, + } + }, + 'Browser.closePage': { + params: { + pageId: t.String, + }, + }, + 'Page.enable': { + params: { + pageId: t.String, + }, + }, + 'Page.setViewport': { + params: { + pageId: t.String, + viewport: t.Nullable({ + width: t.Number, + height: t.Number, + deviceScaleFactor: t.Number, + isMobile: t.Boolean, + hasTouch: t.Boolean, + isLandscape: t.Boolean, + }), + }, + }, + 'Page.evaluate': { + params: t.Either({ + pageId: t.String, + frameId: t.String, + functionText: t.String, + returnByValue: t.Optional(t.Boolean), + args: t.Array(t.Either( + { objectId: t.String }, + { unserializableValue: t.Enum(['Infinity', '-Infinity', '-0', 'NaN']) }, + { value: t.Any }, + )), + }, { + pageId: t.String, + frameId: t.String, + script: t.String, + returnByValue: t.Optional(t.Boolean), + }), + + returns: { + result: t.Optional(RemoteObject), + exceptionDetails: t.Optional({ + text: t.Optional(t.String), + stack: t.Optional(t.String), + value: t.Optional(t.Any), + }), + } + }, + 'Page.addScriptToEvaluateOnNewDocument': { + params: { + pageId: t.String, + script: t.String, + }, + returns: { + scriptId: t.String, + } + }, + 'Page.removeScriptToEvaluateOnNewDocument': { + params: { + pageId: t.String, + scriptId: t.String, + }, + }, + 'Page.disposeObject': { + params: { + pageId: t.String, + frameId: t.String, + objectId: t.String, + }, + }, + + 'Page.getObjectProperties': { + params: { + pageId: t.String, + frameId: t.String, + objectId: t.String, + }, + + returns: { + properties: t.Array({ + name: t.String, + value: RemoteObject, + }), + } + }, + 'Page.navigate': { + params: { + pageId: t.String, + frameId: t.String, + url: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'Page.goBack': { + params: { + pageId: t.String, + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'Page.goForward': { + params: { + pageId: t.String, + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'Page.reload': { + params: { + pageId: t.String, + frameId: t.String, + }, + returns: { + navigationId: t.String, + navigationURL: t.String, + } + }, + 'Page.getBoundingBox': { + params: { + pageId: t.String, + frameId: t.String, + objectId: t.String, + }, + returns: t.Nullable({ + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, + }), + }, + 'Page.screenshot': { + params: { + pageId: t.String, + mimeType: t.Enum(['image/png', 'image/jpeg']), + fullPage: t.Optional(t.Boolean), + clip: t.Optional({ + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, + }) + }, + returns: { + data: t.String, + } + }, + 'Page.getContentQuads': { + params: { + pageId: t.String, + frameId: t.String, + objectId: t.String, + }, + returns: { + quads: t.Array(DOMQuad), + }, + }, + 'Page.dispatchKeyEvent': { + params: { + pageId: t.String, + type: t.String, + key: t.String, + keyCode: t.Number, + location: t.Number, + code: t.String, + repeat: t.Boolean, + } + }, + 'Page.dispatchMouseEvent': { + params: { + pageId: t.String, + type: t.String, + button: t.Number, + x: t.Number, + y: t.Number, + modifiers: t.Number, + clickCount: t.Optional(t.Number), + buttons: t.Number, + } + }, + 'Page.insertText': { + params: { + pageId: t.String, + text: t.String, + } + }, + 'Page.handleDialog': { + params: { + pageId: t.String, + dialogId: t.String, + accept: t.Boolean, + promptText: t.Optional(t.String), + }, + }, + }, + events: { + 'Browser.tabOpened': { + pageId: t.String, + url: t.String, + }, + 'Browser.tabClosed': { pageId: t.String, }, + 'Browser.tabNavigated': { + pageId: t.String, + url: t.String + }, + 'Page.eventFired': { + pageId: t.String, + frameId: t.String, + name: t.Enum(['load', 'DOMContentLoaded']), + }, + 'Page.uncaughtError': { + pageId: t.String, + frameId: t.String, + message: t.String, + stack: t.String, + }, + 'Page.frameAttached': { + pageId: t.String, + frameId: t.String, + parentFrameId: t.Optional(t.String), + }, + 'Page.frameDetached': { + pageId: t.String, + frameId: t.String, + }, + 'Page.navigationStarted': { + pageId: t.String, + frameId: t.String, + navigationId: t.String, + url: t.String, + }, + 'Page.navigationCommitted': { + pageId: t.String, + frameId: t.String, + navigationId: t.String, + url: t.String, + // frame.id or frame.name + name: t.String, + }, + 'Page.navigationAborted': { + pageId: t.String, + frameId: t.String, + navigationId: t.String, + errorText: t.String, + }, + 'Page.sameDocumentNavigation': { + pageId: t.String, + frameId: t.String, + url: t.String, + }, + 'Page.consoleAPICalled': { + pageId: t.String, + frameId: t.String, + args: t.Array(RemoteObject), + type: t.String, + }, + 'Page.dialogOpened': { + pageId: t.String, + dialogId: t.String, + type: t.Enum(['prompt', 'alert', 'confirm', 'beforeunload']), + message: t.String, + defaultValue: t.Optional(t.String), + }, + 'Page.dialogClosed': { + pageId: t.String, + dialogId: t.String, + }, + }, +} + +function checkScheme(scheme, x, details = {}, path = []) { + if (typeof scheme === 'object') { + for (const [propertyName, check] of Object.entries(scheme)) { + path.push(propertyName); + const result = checkScheme(check, x[propertyName], details, path); + path.pop(); + if (!result) + return false; + } + for (const propertyName of Object.keys(x)) { + if (!scheme[propertyName]) { + path.push(propertyName); + details.propertyName = path.join('.'); + details.propertyValue = x[propertyName]; + details.errorType = 'extra'; + return false; + } + } + return true; + } + const result = scheme(x); + if (!result) { + details.propertyName = path.join('.'); + details.propertyValue = x; + details.errorType = 'unsupported'; + } + return result; +} + +this.protocol = protocol; +this.checkScheme = checkScheme; +this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/experimental/juggler/src/components/juggler.js b/experimental/juggler/src/components/juggler.js new file mode 100644 index 00000000..6a1258cf --- /dev/null +++ b/experimental/juggler/src/components/juggler.js @@ -0,0 +1,63 @@ +const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {TCPListener} = ChromeUtils.import("chrome://juggler/content/server/server.js"); +const {ChromeSession} = ChromeUtils.import("chrome://juggler/content/ChromeSession.js"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const FRAME_SCRIPT = "chrome://juggler/content/content/main.js"; + +// Command Line Handler +function CommandLineHandler() { + this._port = 0; +}; + +CommandLineHandler.prototype = { + classDescription: "Sample command-line handler", + classID: Components.ID('{f7a74a33-e2ab-422d-b022-4fb213dd2639}'), + contractID: "@mozilla.org/remote/juggler;1", + _xpcom_categories: [{ + category: "command-line-handler", + entry: "m-juggler" + }], + + /* nsICommandLineHandler */ + handle: async function(cmdLine) { + const jugglerFlag = cmdLine.handleFlagWithParam("juggler", false); + if (!jugglerFlag || isNaN(jugglerFlag)) + return; + this._port = parseInt(jugglerFlag, 10); + Services.obs.addObserver(this, 'sessionstore-windows-restored'); + }, + + observe: function(subject, topic) { + Services.obs.removeObserver(this, 'sessionstore-windows-restored'); + + this._server = new TCPListener(); + this._sessions = new Map(); + this._server.onconnectioncreated = connection => { + this._sessions.set(connection, new ChromeSession(connection)); + } + this._server.onconnectionclosed = connection => { + this._sessions.delete(connection); + } + const runningPort = this._server.start(this._port); + Services.mm.loadFrameScript(FRAME_SCRIPT, true /* aAllowDelayedLoad */); + dump('Juggler listening on ' + runningPort + '\n'); + }, + + QueryInterface: ChromeUtils.generateQI([ Ci.nsICommandLineHandler ]), + + // CHANGEME: change the help info as appropriate, but + // follow the guidelines in nsICommandLineHandler.idl + // specifically, flag descriptions should start at + // character 24, and lines should be wrapped at + // 72 characters with embedded newlines, + // and finally, the string should end with a newline + helpInfo : " --juggler Enable Juggler automation\n" +}; + +var NSGetFactory = XPCOMUtils.generateNSGetFactory([CommandLineHandler]); + diff --git a/experimental/juggler/src/components/juggler.manifest b/experimental/juggler/src/components/juggler.manifest new file mode 100644 index 00000000..50f89302 --- /dev/null +++ b/experimental/juggler/src/components/juggler.manifest @@ -0,0 +1,3 @@ +component {f7a74a33-e2ab-422d-b022-4fb213dd2639} juggler.js +contract @mozilla.org/remote/juggler;1 {f7a74a33-e2ab-422d-b022-4fb213dd2639} +category command-line-handler m-juggler @mozilla.org/remote/juggler;1 diff --git a/experimental/juggler/src/components/moz.build b/experimental/juggler/src/components/moz.build new file mode 100644 index 00000000..268fbc36 --- /dev/null +++ b/experimental/juggler/src/components/moz.build @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_COMPONENTS += [ + "juggler.js", + "juggler.manifest", +] + diff --git a/experimental/juggler/src/content/ContentSession.js b/experimental/juggler/src/content/ContentSession.js new file mode 100644 index 00000000..343a695b --- /dev/null +++ b/experimental/juggler/src/content/ContentSession.js @@ -0,0 +1,53 @@ +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {RuntimeAgent} = ChromeUtils.import('chrome://juggler/content/content/RuntimeAgent.js'); +const {PageAgent} = ChromeUtils.import('chrome://juggler/content/content/PageAgent.js'); + +const helper = new Helper(); + +class ContentSession { + /** + * @param {string} sessionId + * @param {!ContentFrameMessageManager} messageManager + * @param {!FrameTree} frameTree + */ + constructor(sessionId, messageManager, frameTree, scrollbarManager) { + this._sessionId = sessionId; + this._runtimeAgent = new RuntimeAgent(); + this._messageManager = messageManager; + this._pageAgent = new PageAgent(this, this._runtimeAgent, frameTree, scrollbarManager); + this._eventListeners = [ + helper.addMessageListener(messageManager, this._sessionId, this._onMessage.bind(this)), + ]; + } + + emitEvent(eventName, params) { + this._messageManager.sendAsyncMessage(this._sessionId, {eventName, params}); + } + + mm() { + return this._messageManager; + } + + async _onMessage(msg) { + const id = msg.data.id; + try { + const handler = this._pageAgent[msg.data.methodName]; + if (!handler) + throw new Error('unknown method: "' + msg.data.methodName + '"'); + const result = await handler.call(this._pageAgent, msg.data.params); + this._messageManager.sendAsyncMessage(this._sessionId, {id, result}); + } catch (e) { + this._messageManager.sendAsyncMessage(this._sessionId, {id, error: e.message + '\n' + e.stack}); + } + } + + dispose() { + helper.removeListeners(this._eventListeners); + this._pageAgent.dispose(); + this._runtimeAgent.dispose(); + } +} + +var EXPORTED_SYMBOLS = ['ContentSession']; +this.ContentSession = ContentSession; + diff --git a/experimental/juggler/src/content/FrameTree.js b/experimental/juggler/src/content/FrameTree.js new file mode 100644 index 00000000..b420618e --- /dev/null +++ b/experimental/juggler/src/content/FrameTree.js @@ -0,0 +1,287 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); + +const helper = new Helper(); + +class FrameTree { + constructor(rootDocShell) { + EventEmitter.decorate(this); + this._docShellToFrame = new Map(); + this._frameIdToFrame = new Map(); + this._mainFrame = this._createFrame(rootDocShell); + const webProgress = rootDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this.QueryInterface = ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]); + + const flags = Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_FRAME_LOCATION; + this._eventListeners = [ + helper.addObserver(subject => this._onDocShellCreated(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-create'), + helper.addObserver(subject => this._onDocShellDestroyed(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-destroy'), + helper.addProgressListener(webProgress, this, flags), + ]; + } + + frameForDocShell(docShell) { + return this._docShellToFrame.get(docShell) || null; + } + + frame(frameId) { + return this._frameIdToFrame.get(frameId) || null; + } + + frames() { + let result = []; + collect(this._mainFrame); + return result; + + function collect(frame) { + result.push(frame); + for (const subframe of frame._children) + collect(subframe); + } + } + + mainFrame() { + return this._mainFrame; + } + + dispose() { + helper.removeListeners(this._eventListeners); + } + + onStateChange(progress, request, flag, status) { + if (!(request instanceof Ci.nsIChannel)) + return; + const channel = request.QueryInterface(Ci.nsIChannel); + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + if (!frame) { + dump(`ERROR: got a state changed event for un-tracked docshell!\n`); + return; + } + + const isStart = flag & Ci.nsIWebProgressListener.STATE_START; + const isTransferring = flag & Ci.nsIWebProgressListener.STATE_TRANSFERRING; + const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + + if (isStart) { + // Starting a new navigation. + frame._pendingNavigationId = helper.generateId(); + frame._pendingNavigationURL = channel.URI.spec; + this.emit(FrameTree.Events.NavigationStarted, frame); + } else if (isTransferring || (isStop && frame._pendingNavigationId && !status)) { + // Navigation is committed. + for (const subframe of frame._children) + this._detachFrame(subframe); + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + frame._lastCommittedNavigationId = navigationId; + frame._url = channel.URI.spec; + this.emit(FrameTree.Events.NavigationCommitted, frame); + } else if (isStop && frame._pendingNavigationId && status) { + // Navigation is aborted. + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + this.emit(FrameTree.Events.NavigationAborted, frame, navigationId, getErrorStatusText(status)); + } + } + + onFrameLocationChange(progress, request, location, flags) { + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + const sameDocumentNavigation = !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + if (frame && sameDocumentNavigation) { + frame._url = location.spec; + this.emit(FrameTree.Events.SameDocumentNavigation, frame); + } + } + + _onDocShellCreated(docShell) { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) + return; + // If this docShell doesn't belong to our frame tree - do nothing. + let root = docShell; + while (root.parent) + root = root.parent; + if (root === this._mainFrame._docShell) + this._createFrame(docShell); + } + + _createFrame(docShell) { + const parentFrame = this._docShellToFrame.get(docShell.parent) || null; + const frame = new Frame(this, docShell, parentFrame); + this._docShellToFrame.set(docShell, frame); + this._frameIdToFrame.set(frame.id(), frame); + this.emit(FrameTree.Events.FrameAttached, frame); + return frame; + } + + _onDocShellDestroyed(docShell) { + const frame = this._docShellToFrame.get(docShell); + if (frame) + this._detachFrame(frame); + } + + _detachFrame(frame) { + // Detach all children first + for (const subframe of frame._children) + this._detachFrame(subframe); + this._docShellToFrame.delete(frame._docShell); + this._frameIdToFrame.delete(frame.id()); + if (frame._parentFrame) + frame._parentFrame._children.delete(frame); + frame._parentFrame = null; + this.emit(FrameTree.Events.FrameDetached, frame); + } +} + +FrameTree.Events = { + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + NavigationStarted: 'navigationstarted', + NavigationCommitted: 'navigationcommitted', + NavigationAborted: 'navigationaborted', + SameDocumentNavigation: 'samedocumentnavigation', +}; + +class Frame { + constructor(frameTree, docShell, parentFrame) { + this._frameTree = frameTree; + this._docShell = docShell; + this._children = new Set(); + this._frameId = helper.generateId(); + this._parentFrame = null; + this._url = ''; + if (parentFrame) { + this._parentFrame = parentFrame; + parentFrame._children.add(this); + } + + this._lastCommittedNavigationId = null; + this._pendingNavigationId = null; + this._pendingNavigationURL = null; + + this._textInputProcessor = null; + } + + textInputProcessor() { + if (!this._textInputProcessor) { + this._textInputProcessor = Cc["@mozilla.org/text-input-processor;1"].createInstance(Ci.nsITextInputProcessor); + this._textInputProcessor.beginInputTransactionForTests(this._docShell.DOMWindow); + } + return this._textInputProcessor; + } + + pendingNavigationId() { + return this._pendingNavigationId; + } + + pendingNavigationURL() { + return this._pendingNavigationURL; + } + + lastCommittedNavigationId() { + return this._lastCommittedNavigationId; + } + + docShell() { + return this._docShell; + } + + domWindow() { + return this._docShell.DOMWindow; + } + + name() { + const frameElement = this._docShell.domWindow.frameElement; + let name = ''; + if (frameElement) + name = frameElement.getAttribute('name') || frameElement.getAttribute('id') || ''; + return name; + } + + parentFrame() { + return this._parentFrame; + } + + id() { + return this._frameId; + } + + url() { + return this._url; + } +} + +function getErrorStatusText(status) { + if (!status) + return null; + for (const key of Object.keys(Cr)) { + if (Cr[key] === status) + return key; + } + // Security module. The following is taken from + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL + if ((status & 0xff0000) === 0x5a0000) { + // NSS_SEC errors (happen below the base value because of negative vals) + if ((status & 0xffff) < Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)) { + // The bases are actually negative, so in our positive numeric space, we + // need to subtract the base off our value. + const nssErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); + switch (nssErr) { + case 11: + return 'SEC_ERROR_EXPIRED_CERTIFICATE'; + case 12: + return 'SEC_ERROR_REVOKED_CERTIFICATE'; + case 13: + return 'SEC_ERROR_UNKNOWN_ISSUER'; + case 20: + return 'SEC_ERROR_UNTRUSTED_ISSUER'; + case 21: + return 'SEC_ERROR_UNTRUSTED_CERT'; + case 36: + return 'SEC_ERROR_CA_CERT_INVALID'; + case 90: + return 'SEC_ERROR_INADEQUATE_KEY_USAGE'; + case 176: + return 'SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED'; + default: + return 'SEC_ERROR_UNKNOWN'; + } + } + const sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); + switch (sslErr) { + case 3: + return 'SSL_ERROR_NO_CERTIFICATE'; + case 4: + return 'SSL_ERROR_BAD_CERTIFICATE'; + case 8: + return 'SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE'; + case 9: + return 'SSL_ERROR_UNSUPPORTED_VERSION'; + case 12: + return 'SSL_ERROR_BAD_CERT_DOMAIN'; + default: + return 'SSL_ERROR_UNKNOWN'; + } + } + return ''; +} + +var EXPORTED_SYMBOLS = ['FrameTree']; +this.FrameTree = FrameTree; + diff --git a/experimental/juggler/src/content/PageAgent.js b/experimental/juggler/src/content/PageAgent.js new file mode 100644 index 00000000..98f7777b --- /dev/null +++ b/experimental/juggler/src/content/PageAgent.js @@ -0,0 +1,460 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); + +const helper = new Helper(); + +class PageAgent { + constructor(session, runtimeAgent, frameTree, scrollbarManager) { + this._session = session; + this._runtime = runtimeAgent; + this._frameTree = frameTree; + this._scrollbarManager = scrollbarManager; + + this._frameToExecutionContext = new Map(); + this._scriptsToEvaluateOnNewDocument = new Map(); + + this._eventListeners = []; + this._enabled = false; + + const docShell = frameTree.mainFrame().docShell(); + this._initialDPPX = docShell.contentViewer.overrideDPPX; + this._customScrollbars = null; + } + + async awaitViewportDimensions({width, height}) { + const win = this._frameTree.mainFrame().domWindow(); + if (win.innerWidth === width && win.innerHeight === height) + return; + await new Promise(resolve => { + const listener = helper.addEventListener(win, 'resize', () => { + if (win.innerWidth === width && win.innerHeight === height) { + helper.removeListeners([listener]); + resolve(); + } + }); + }); + } + + async setViewport({deviceScaleFactor, isMobile, hasTouch}) { + const docShell = this._frameTree.mainFrame().docShell(); + docShell.contentViewer.overrideDPPX = deviceScaleFactor || this._initialDPPX; + docShell.deviceSizeIsPageSize = isMobile; + docShell.touchEventsOverride = hasTouch ? Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED : Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_NONE; + this._scrollbarManager.setFloatingScrollbars(isMobile); + } + + addScriptToEvaluateOnNewDocument({script}) { + const scriptId = helper.generateId(); + this._scriptsToEvaluateOnNewDocument.set(scriptId, script); + return {scriptId}; + } + + removeScriptToEvaluateOnNewDocument({scriptId}) { + this._scriptsToEvaluateOnNewDocument.delete(scriptId); + } + + enable() { + if (this._enabled) + return; + + this._enabled = true; + this._eventListeners = [ + helper.addObserver(this._consoleAPICalled.bind(this), "console-api-log-event"), + helper.addEventListener(this._session.mm(), 'DOMContentLoaded', this._onDOMContentLoaded.bind(this)), + helper.addEventListener(this._session.mm(), 'pageshow', this._onLoad.bind(this)), + helper.addEventListener(this._session.mm(), 'DOMWindowCreated', this._onDOMWindowCreated.bind(this)), + helper.addEventListener(this._session.mm(), 'error', this._onError.bind(this)), + helper.on(this._frameTree, 'frameattached', this._onFrameAttached.bind(this)), + helper.on(this._frameTree, 'framedetached', this._onFrameDetached.bind(this)), + helper.on(this._frameTree, 'navigationstarted', this._onNavigationStarted.bind(this)), + helper.on(this._frameTree, 'navigationcommitted', this._onNavigationCommitted.bind(this)), + helper.on(this._frameTree, 'navigationaborted', this._onNavigationAborted.bind(this)), + helper.on(this._frameTree, 'samedocumentnavigation', this._onSameDocumentNavigation.bind(this)), + ]; + + // Dispatch frameAttached events for all initial frames + for (const frame of this._frameTree.frames()) { + this._onFrameAttached(frame); + if (frame.url()) + this._onNavigationCommitted(frame); + if (frame.pendingNavigationId()) + this._onNavigationStarted(frame); + } + } + + _onDOMContentLoaded(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.eventFired', { + frameId: frame.id(), + name: 'DOMContentLoaded', + }); + } + + _onError(errorEvent) { + const docShell = errorEvent.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.uncaughtError', { + frameId: frame.id(), + message: errorEvent.message, + stack: errorEvent.error.stack + }); + } + + _onLoad(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.eventFired', { + frameId: frame.id(), + name: 'load' + }); + } + + _onNavigationStarted(frame) { + this._session.emitEvent('Page.navigationStarted', { + frameId: frame.id(), + navigationId: frame.pendingNavigationId(), + url: frame.pendingNavigationURL(), + }); + } + + _onNavigationAborted(frame, navigationId, errorText) { + this._session.emitEvent('Page.navigationAborted', { + frameId: frame.id(), + navigationId, + errorText, + }); + } + + _onSameDocumentNavigation(frame) { + this._session.emitEvent('Page.sameDocumentNavigation', { + frameId: frame.id(), + url: frame.url(), + }); + } + + _onNavigationCommitted(frame) { + const context = this._frameToExecutionContext.get(frame); + if (context) { + this._runtime.destroyExecutionContext(context); + this._frameToExecutionContext.delete(frame); + } + this._session.emitEvent('Page.navigationCommitted', { + frameId: frame.id(), + navigationId: frame.lastCommittedNavigationId(), + url: frame.url(), + name: frame.name(), + }); + } + + _onDOMWindowCreated(event) { + if (!this._scriptsToEvaluateOnNewDocument.size) + return; + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + const executionContext = this._ensureExecutionContext(frame); + for (const script of this._scriptsToEvaluateOnNewDocument.values()) { + try { + let result = executionContext.evaluateScript(script); + if (result && result.objectId) + executionContext.disposeObject(result.objectId); + } catch (e) { + } + } + } + + _onFrameAttached(frame) { + this._session.emitEvent('Page.frameAttached', { + frameId: frame.id(), + parentFrameId: frame.parentFrame() ? frame.parentFrame().id() : undefined, + }); + } + + _onFrameDetached(frame) { + this._session.emitEvent('Page.frameDetached', { + frameId: frame.id(), + }); + } + + _ensureExecutionContext(frame) { + let executionContext = this._frameToExecutionContext.get(frame); + if (!executionContext) { + executionContext = this._runtime.createExecutionContext(frame.domWindow()); + this._frameToExecutionContext.set(frame, executionContext); + } + return executionContext; + } + + dispose() { + helper.removeListeners(this._eventListeners); + } + + _consoleAPICalled({wrappedJSObject}, topic, data) { + const levelToType = { + 'dir': 'dir', + 'log': 'log', + 'debug': 'debug', + 'info': 'info', + 'error': 'error', + 'warn': 'warning', + 'dirxml': 'dirxml', + 'table': 'table', + 'trace': 'trace', + 'clear': 'clear', + 'group': 'startGroup', + 'groupCollapsed': 'startGroupCollapsed', + 'groupEnd': 'endGroup', + 'assert': 'assert', + 'profile': 'profile', + 'profileEnd': 'profileEnd', + 'count': 'count', + 'countReset': 'countReset', + 'time': null, + 'timeLog': 'timeLog', + 'timeEnd': 'timeEnd', + 'timeStamp': 'timeStamp', + }; + const type = levelToType[wrappedJSObject.level]; + if (!type) return; + let messageFrame = null; + for (const frame of this._frameTree.frames()) { + const domWindow = frame.domWindow(); + if (domWindow && domWindow.windowUtils.currentInnerWindowID === wrappedJSObject.innerID) { + messageFrame = frame; + break; + } + } + if (!messageFrame) + return; + const executionContext = this._ensureExecutionContext(messageFrame); + const args = wrappedJSObject.arguments.map(arg => executionContext.rawValueToRemoteObject(arg)); + this._session.emitEvent('Page.consoleAPICalled', {args, type, frameId: messageFrame.id()}); + } + + async navigate({frameId, url}) { + try { + const uri = NetUtil.newURI(url); + } catch (e) { + throw new Error(`Invalid url: "${url}"`); + } + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + docShell.loadURI(url, Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null /* referrer */, null /* postData */, null /* headers */); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async reload({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + docShell.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async goBack({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoBack) + return {navigationId: null, navigationURL: null}; + docShell.goBack(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async goForward({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoForward) + return {navigationId: null, navigationURL: null}; + docShell.goForward(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async disposeObject({frameId, objectId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const executionContext = this._ensureExecutionContext(frame); + return executionContext.disposeObject(objectId); + } + + getContentQuads({objectId, frameId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const executionContext = this._ensureExecutionContext(frame); + const unsafeObject = executionContext.unsafeObject(objectId); + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}).map(quad => { + return { + p1: {x: quad.p1.x, y: quad.p1.y}, + p2: {x: quad.p2.x, y: quad.p2.y}, + p3: {x: quad.p3.x, y: quad.p3.y}, + p4: {x: quad.p4.x, y: quad.p4.y}, + }; + }); + return {quads}; + } + + async getBoundingBox({frameId, objectId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const executionContext = this._ensureExecutionContext(frame); + const unsafeObject = executionContext.unsafeObject(objectId); + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}); + if (!quads.length) + return null; + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const quad of quads) { + const boundingBox = quad.getBounds(); + x1 = Math.min(boundingBox.x, x1); + y1 = Math.min(boundingBox.y, y1); + x2 = Math.max(boundingBox.x + boundingBox.width, x2); + y2 = Math.max(boundingBox.y + boundingBox.height, y2); + } + return {x: x1 + frame.domWindow().scrollX, y: y1 + frame.domWindow().scrollY, width: x2 - x1, height: y2 - y1}; + } + + async evaluate({frameId, functionText, args, script, returnByValue}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const executionContext = this._ensureExecutionContext(frame); + const exceptionDetails = {}; + let result = null; + if (script) + result = await executionContext.evaluateScript(script, exceptionDetails); + else + result = await executionContext.evaluateFunction(functionText, args, exceptionDetails); + if (!result) + return {exceptionDetails}; + let isNode = undefined; + if (returnByValue) + result = executionContext.ensureSerializedToValue(result); + return {result}; + } + + async getObjectProperties({frameId, objectId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const executionContext = this._ensureExecutionContext(frame); + return {properties: executionContext.getObjectProperties(objectId)}; + } + + async screenshot({mimeType, fullPage, frameId, objectId, clip}) { + const content = this._session.mm().content; + if (clip) { + const data = takeScreenshot(content, clip.x, clip.y, clip.width, clip.height, mimeType); + return {data}; + } + if (fullPage) { + const rect = content.document.documentElement.getBoundingClientRect(); + const width = content.innerWidth + content.scrollMaxX - content.scrollMinX; + const height = content.innerHeight + content.scrollMaxY - content.scrollMinY; + const data = takeScreenshot(content, 0, 0, width, height, mimeType); + return {data}; + } + const data = takeScreenshot(content, content.scrollX, content.scrollY, content.innerWidth, content.innerHeight, mimeType); + return {data}; + } + + async dispatchKeyEvent({type, keyCode, code, key, repeat, location}) { + const frame = this._frameTree.mainFrame(); + const tip = frame.textInputProcessor(); + let keyEvent = new (frame.domWindow().KeyboardEvent)("", { + key, + code, + location, + repeat, + keyCode + }); + const flags = 0; + if (type === 'keydown') + tip.keydown(keyEvent, flags); + else if (type === 'keyup') + tip.keyup(keyEvent, flags); + else + throw new Error(`Unknown type ${type}`); + } + + async dispatchMouseEvent({type, x, y, button, clickCount, modifiers, buttons}) { + const frame = this._frameTree.mainFrame(); + frame.domWindow().windowUtils.sendMouseEvent( + type, + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + if (type === 'mousedown' && button === 2) { + frame.domWindow().windowUtils.sendMouseEvent( + 'contextmenu', + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + } + } + + async insertText({text}) { + const frame = this._frameTree.mainFrame(); + frame.textInputProcessor().commitCompositionWith(text); + } +} + +function takeScreenshot(win, left, top, width, height, mimeType) { + const MAX_SKIA_DIMENSIONS = 32767; + + const scale = win.devicePixelRatio; + const canvasWidth = width * scale; + const canvasHeight = height * scale; + + if (canvasWidth > MAX_SKIA_DIMENSIONS || canvasHeight > MAX_SKIA_DIMENSIONS) + throw new Error('Cannot take screenshot larger than ' + MAX_SKIA_DIMENSIONS); + + const canvas = win.document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + let ctx = canvas.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawWindow(win, left, top, width, height, 'rgb(255,255,255)', ctx.DRAWWINDOW_DRAW_CARET); + const dataURL = canvas.toDataURL(mimeType); + return dataURL.substring(dataURL.indexOf(',') + 1); +}; + +var EXPORTED_SYMBOLS = ['PageAgent']; +this.PageAgent = PageAgent; + diff --git a/experimental/juggler/src/content/RuntimeAgent.js b/experimental/juggler/src/content/RuntimeAgent.js new file mode 100644 index 00000000..6c849355 --- /dev/null +++ b/experimental/juggler/src/content/RuntimeAgent.js @@ -0,0 +1,275 @@ +"use strict"; +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {addDebuggerToGlobal} = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm", {}); + +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; +addDebuggerToGlobal(Cu.getGlobalForObject(this)); +const helper = new Helper(); + +class RuntimeAgent { + constructor() { + this._debugger = new Debugger(); + this._pendingPromises = new Map(); + } + + dispose() {} + + async _awaitPromise(executionContext, obj, exceptionDetails = {}) { + if (obj.promiseState === 'fulfilled') + return {success: true, obj: obj.promiseValue}; + if (obj.promiseState === 'rejected') { + const global = executionContext._global; + exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + return {success: false, obj: null}; + } + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + this._pendingPromises.set(obj.promiseID, {resolve, reject, executionContext, exceptionDetails}); + if (this._pendingPromises.size === 1) + this._debugger.onPromiseSettled = this._onPromiseSettled.bind(this); + return await promise; + } + + _onPromiseSettled(obj) { + const pendingPromise = this._pendingPromises.get(obj.promiseID); + if (!pendingPromise) + return; + this._pendingPromises.delete(obj.promiseID); + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + + if (obj.promiseState === 'fulfilled') { + pendingPromise.resolve({success: true, obj: obj.promiseValue}); + return; + }; + const global = pendingPromise.executionContext._global; + pendingPromise.exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + pendingPromise.exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + pendingPromise.resolve({success: false, obj: null}); + } + + createExecutionContext(domWindow) { + return new ExecutionContext(this, domWindow, this._debugger.addDebuggee(domWindow)); + } + + destroyExecutionContext(destroyedContext) { + for (const [promiseID, {reject, executionContext}] of this._pendingPromises) { + if (executionContext === destroyedContext) { + reject(new Error('Execution context was destroyed!')); + this._pendingPromises.delete(promiseID); + } + } + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + this._debugger.removeDebuggee(destroyedContext._domWindow); + } +} + +class ExecutionContext { + constructor(runtime, DOMWindow, global) { + this._runtime = runtime; + this._domWindow = DOMWindow; + this._global = global; + this._remoteObjects = new Map(); + } + + async evaluateScript(script, exceptionDetails = {}) { + const userInputHelper = this._domWindow.windowUtils.setHandlingUserInput(true); + let {success, obj} = this._getResult(this._global.executeInGlobal(script), exceptionDetails); + userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + async evaluateFunction(functionText, args, exceptionDetails = {}) { + const funEvaluation = this._getResult(this._global.executeInGlobal('(' + functionText + ')'), exceptionDetails); + if (!funEvaluation.success) + return null; + if (!funEvaluation.obj.callable) + throw new Error('functionText does not evaluate to a function!'); + args = args.map(arg => { + if (arg.objectId) { + if (!this._remoteObjects.has(arg.objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + return this._remoteObjects.get(arg.objectId); + } + switch (arg.unserializableValue) { + case 'Infinity': return Infinity; + case '-Infinity': return -Infinity; + case '-0': return -0; + case 'NaN': return NaN; + default: return this._toDebugger(arg.value); + } + }); + const userInputHelper = this._domWindow.windowUtils.setHandlingUserInput(true); + let {success, obj} = this._getResult(funEvaluation.obj.apply(null, args), exceptionDetails); + userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + unsafeObject(objectId) { + if (!this._remoteObjects.has(objectId)) + throw new Error('Cannot find object with id = ' + objectId); + return this._remoteObjects.get(objectId).unsafeDereference(); + } + + rawValueToRemoteObject(rawValue) { + const debuggerObj = this._global.makeDebuggeeValue(rawValue); + return this._createRemoteObject(debuggerObj); + } + + _createRemoteObject(debuggerObj) { + if (debuggerObj instanceof Debugger.Object) { + const objectId = helper.generateId(); + this._remoteObjects.set(objectId, debuggerObj); + const rawObj = debuggerObj.unsafeDereference(); + const type = typeof rawObj; + let subtype = undefined; + if (debuggerObj.isProxy) + subtype = 'proxy'; + else if (Array.isArray(rawObj)) + subtype = 'array'; + else if (Object.is(rawObj, null)) + subtype = 'null'; + else if (rawObj instanceof this._domWindow.Node) + subtype = 'node'; + else if (rawObj instanceof this._domWindow.RegExp) + subtype = 'regexp'; + else if (rawObj instanceof this._domWindow.Date) + subtype = 'date'; + else if (rawObj instanceof this._domWindow.Map) + subtype = 'map'; + else if (rawObj instanceof this._domWindow.Set) + subtype = 'set'; + else if (rawObj instanceof this._domWindow.WeakMap) + subtype = 'weakmap'; + else if (rawObj instanceof this._domWindow.WeakSet) + subtype = 'weakset'; + else if (rawObj instanceof this._domWindow.Error) + subtype = 'error'; + else if (rawObj instanceof this._domWindow.Promise) + subtype = 'promise'; + else if ((rawObj instanceof this._domWindow.Int8Array) || (rawObj instanceof this._domWindow.Uint8Array) || + (rawObj instanceof this._domWindow.Uint8ClampedArray) || (rawObj instanceof this._domWindow.Int16Array) || + (rawObj instanceof this._domWindow.Uint16Array) || (rawObj instanceof this._domWindow.Int32Array) || + (rawObj instanceof this._domWindow.Uint32Array) || (rawObj instanceof this._domWindow.Float32Array) || + (rawObj instanceof this._domWindow.Float64Array)) { + subtype = 'typedarray'; + } + const isNode = debuggerObj.unsafeDereference() instanceof this._domWindow.Node; + return {objectId, type, subtype}; + } + if (typeof debuggerObj === 'symbol') { + const objectId = helper.generateId(); + this._remoteObjects.set(objectId, debuggerObj); + return {objectId, type: 'symbol'}; + } + + let unserializableValue = undefined; + if (Object.is(debuggerObj, NaN)) + unserializableValue = 'NaN'; + else if (Object.is(debuggerObj, -0)) + unserializableValue = '-0'; + else if (Object.is(debuggerObj, Infinity)) + unserializableValue = 'Infinity'; + else if (Object.is(debuggerObj, -Infinity)) + unserializableValue = '-Infinity'; + return unserializableValue ? {unserializableValue} : {value: debuggerObj}; + } + + ensureSerializedToValue(protocolObject) { + if (!protocolObject.objectId) + return protocolObject; + const obj = this._remoteObjects.get(protocolObject.objectId); + this._remoteObjects.delete(protocolObject.objectId); + return {value: this._serialize(obj)}; + } + + _toDebugger(obj) { + if (typeof obj !== 'object') + return obj; + const properties = {}; + for (let [key, value] of Object.entries(obj)) { + properties[key] = { + writable: true, + enumerable: true, + value: this._toDebugger(value), + }; + } + const baseObject = Array.isArray(obj) ? '([])' : '({})'; + const debuggerObj = this._global.executeInGlobal(baseObject).return; + debuggerObj.defineProperties(properties); + return debuggerObj; + } + + _serialize(obj) { + const result = this._global.executeInGlobalWithBindings('JSON.stringify(e)', {e: obj}); + if (result.throw) + throw new Error('Object is not serializable'); + return JSON.parse(result.return); + } + + disposeObject(objectId) { + this._remoteObjects.delete(objectId); + } + + getObjectProperties(objectId) { + if (!this._remoteObjects.has(objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + const result = []; + for (let obj = this._remoteObjects.get(objectId); obj; obj = obj.proto) { + for (const propertyName of obj.getOwnPropertyNames()) { + const descriptor = obj.getOwnPropertyDescriptor(propertyName); + if (!descriptor.enumerable) + continue; + result.push({ + name: propertyName, + value: this._createRemoteObject(descriptor.value), + }); + } + } + return result; + } + + _getResult(completionValue, exceptionDetails = {}) { + if (!completionValue) { + exceptionDetails.text = 'Evaluation terminated!'; + exceptionDetails.stack = ''; + return {success: false, obj: null}; + } + if (completionValue.throw) { + if (this._global.executeInGlobalWithBindings('e instanceof Error', {e: completionValue.throw}).return) { + exceptionDetails.text = this._global.executeInGlobalWithBindings('e.message', {e: completionValue.throw}).return; + exceptionDetails.stack = this._global.executeInGlobalWithBindings('e.stack', {e: completionValue.throw}).return; + } else { + exceptionDetails.value = this._serialize(completionValue.throw); + } + return {success: false, obj: null}; + } + return {success: true, obj: completionValue.return}; + } +} + +var EXPORTED_SYMBOLS = ['RuntimeAgent']; +this.RuntimeAgent = RuntimeAgent; diff --git a/experimental/juggler/src/content/ScrollbarManager.js b/experimental/juggler/src/content/ScrollbarManager.js new file mode 100644 index 00000000..840b85d6 --- /dev/null +++ b/experimental/juggler/src/content/ScrollbarManager.js @@ -0,0 +1,58 @@ +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; +const Cc = Components.classes; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const HIDDEN_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/hidden-scrollbars.css'); +const FLOATING_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/floating-scrollbars.css'); + +const isHeadless = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless; +const helper = new Helper(); + +class ScrollbarManager { + constructor(mm, docShell) { + this._docShell = docShell; + this._customScrollbars = null; + + if (isHeadless) + this._setCustomScrollbars(HIDDEN_SCROLLBARS); + + this._eventListeners = [ + helper.addEventListener(mm, 'DOMWindowCreated', this._onDOMWindowCreated.bind(this)), + ]; + } + + setFloatingScrollbars(enabled) { + if (this._customScrollbars === HIDDEN_SCROLLBARS) + return; + this._setCustomScrollbars(enabled ? FLOATING_SCROLLBARS : null); + } + + _setCustomScrollbars(customScrollbars) { + if (this._customScrollbars === customScrollbars) + return; + if (this._customScrollbars) + this._docShell.domWindow.windowUtils.removeSheet(this._customScrollbars, this._docShell.domWindow.AGENT_SHEET); + this._customScrollbars = customScrollbars; + if (this._customScrollbars) + this._docShell.domWindow.windowUtils.loadSheet(this._customScrollbars, this._docShell.domWindow.AGENT_SHEET); + } + + dispose() { + this._setCustomScrollbars(null); + helper.removeListeners(this._eventListeners); + } + + _onDOMWindowCreated(event) { + const docShell = event.target.ownerGlobal.docShell; + if (this._customScrollbars) + docShell.domWindow.windowUtils.loadSheet(this._customScrollbars, docShell.domWindow.AGENT_SHEET); + } +} + +var EXPORTED_SYMBOLS = ['ScrollbarManager']; +this.ScrollbarManager = ScrollbarManager; + diff --git a/experimental/juggler/src/content/floating-scrollbars.css b/experimental/juggler/src/content/floating-scrollbars.css new file mode 100644 index 00000000..7709bdd3 --- /dev/null +++ b/experimental/juggler/src/content/floating-scrollbars.css @@ -0,0 +1,47 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + position: relative; + background-color: transparent; + background-image: none; + z-index: 2147483647; + padding: 2px; + border: none; +} + +/* Scrollbar code will reset the margin to the correct side depending on + where layout actually puts the scrollbar */ +*|*:not(html|select) > scrollbar[orient="vertical"] { + margin-left: -10px; + min-width: 10px; + max-width: 10px; +} + +*|*:not(html|select) > scrollbar[orient="horizontal"] { + margin-top: -10px; + min-height: 10px; + max-height: 10px; +} + +*|*:not(html|select) > scrollbar slider { + -moz-appearance: none !important; +} + +*|*:not(html|select) > scrollbar thumb { + -moz-appearance: none !important; + background-color: rgba(0,0,0,0.2); + border-width: 0px !important; + border-radius: 3px !important; +} + +*|*:not(html|select) > scrollbar scrollbarbutton, +*|*:not(html|select) > scrollbar gripper { + display: none; +} diff --git a/experimental/juggler/src/content/hidden-scrollbars.css b/experimental/juggler/src/content/hidden-scrollbars.css new file mode 100644 index 00000000..3a386425 --- /dev/null +++ b/experimental/juggler/src/content/hidden-scrollbars.css @@ -0,0 +1,13 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + display: none; +} + diff --git a/experimental/juggler/src/content/main.js b/experimental/juggler/src/content/main.js new file mode 100644 index 00000000..d882ea9f --- /dev/null +++ b/experimental/juggler/src/content/main.js @@ -0,0 +1,27 @@ +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {ContentSession} = ChromeUtils.import('chrome://juggler/content/content/ContentSession.js'); +const {FrameTree} = ChromeUtils.import('chrome://juggler/content/content/FrameTree.js'); +const {ScrollbarManager} = ChromeUtils.import('chrome://juggler/content/content/ScrollbarManager.js'); + +const sessions = new Map(); +const frameTree = new FrameTree(docShell); +const scrollbarManager = new ScrollbarManager(this, docShell); + +const helper = new Helper(); + +const gListeners = [ + helper.addMessageListener(this, 'juggler:create-content-session', msg => { + const sessionId = msg.data; + sessions.set(sessionId, new ContentSession(sessionId, this, frameTree, scrollbarManager)); + }), + + helper.addEventListener(this, 'unload', msg => { + helper.removeListeners(gListeners); + for (const session of sessions.values()) + session.dispose(); + sessions.clear(); + scrollbarManager.dispose(); + frameTree.dispose(); + }), +]; + diff --git a/experimental/juggler/src/jar.mn b/experimental/juggler/src/jar.mn new file mode 100644 index 00000000..8f69d540 --- /dev/null +++ b/experimental/juggler/src/jar.mn @@ -0,0 +1,25 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +juggler.jar: +% content juggler %content/ + content/ChromeSession.js (ChromeSession.js) + content/InsecureSweepingOverride.js (InsecureSweepingOverride.js) + content/Protocol.js (Protocol.js) + content/Helper.js (Helper.js) + content/PageHandler.jsm (PageHandler.jsm) + content/BrowserHandler.jsm (BrowserHandler.jsm) + content/content/main.js (content/main.js) + content/content/ContentSession.js (content/ContentSession.js) + content/content/FrameTree.js (content/FrameTree.js) + content/content/PageAgent.js (content/PageAgent.js) + content/content/RuntimeAgent.js (content/RuntimeAgent.js) + content/content/ScrollbarManager.js (content/ScrollbarManager.js) + content/content/floating-scrollbars.css (content/floating-scrollbars.css) + content/content/hidden-scrollbars.css (content/hidden-scrollbars.css) + content/server/server.js (server/server.js) + content/server/transport.js (server/transport.js) + content/server/stream-utils.js (server/stream-utils.js) + content/server/packets.js (server/packets.js) + diff --git a/experimental/juggler/src/moz.build b/experimental/juggler/src/moz.build new file mode 100644 index 00000000..1a0a3130 --- /dev/null +++ b/experimental/juggler/src/moz.build @@ -0,0 +1,15 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += ["components"] + +JAR_MANIFESTS += ["jar.mn"] +#JS_PREFERENCE_FILES += ["prefs/marionette.js"] + +#MARIONETTE_UNIT_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.ini"] +#XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Juggler") + diff --git a/experimental/juggler/src/server/packets.js b/experimental/juggler/src/server/packets.js new file mode 100644 index 00000000..5fc6eba6 --- /dev/null +++ b/experimental/juggler/src/server/packets.js @@ -0,0 +1,407 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Packets contain read / write functionality for the different packet types + * supported by the debugging protocol, so that a transport can focus on + * delivery and queue management without worrying too much about the specific + * packet types. + * + * They are intended to be "one use only", so a new packet should be + * instantiated for each incoming or outgoing packet. + * + * A complete Packet type should expose at least the following: + * * read(stream, scriptableStream) + * Called when the input stream has data to read + * * write(stream) + * Called when the output stream is ready to write + * * get done() + * Returns true once the packet is done being read / written + * * destroy() + * Called to clean up at the end of use + */ + +const {StreamUtils} = + ChromeUtils.import("chrome://juggler/content/server/stream-utils.js", {}); + +const unicodeConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); +unicodeConverter.charset = "UTF-8"; + +const defer = function() { + let deferred = { + promise: new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }), + }; + return deferred; +}; + +this.EXPORTED_SYMBOLS = ["RawPacket", "Packet", "JSONPacket", "BulkPacket"]; + +// The transport's previous check ensured the header length did not +// exceed 20 characters. Here, we opt for the somewhat smaller, but still +// large limit of 1 TiB. +const PACKET_LENGTH_MAX = Math.pow(2, 40); + +/** + * A generic Packet processing object (extended by two subtypes below). + * + * @class + */ +function Packet(transport) { + this._transport = transport; + this._length = 0; +} + +/** + * Attempt to initialize a new Packet based on the incoming packet header + * we've received so far. We try each of the types in succession, trying + * JSON packets first since they are much more common. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {Packet} + * Parsed packet of the matching type, or null if no types matched. + */ +Packet.fromHeader = function(header, transport) { + return JSONPacket.fromHeader(header, transport) || + BulkPacket.fromHeader(header, transport); +}; + +Packet.prototype = { + + get length() { + return this._length; + }, + + set length(length) { + if (length > PACKET_LENGTH_MAX) { + throw new Error("Packet length " + length + + " exceeds the max length of " + PACKET_LENGTH_MAX); + } + this._length = length; + }, + + destroy() { + this._transport = null; + }, +}; + +/** + * With a JSON packet (the typical packet type sent via the transport), + * data is transferred as a JSON packet serialized into a string, + * with the string length prepended to the packet, followed by a colon + * ([length]:[packet]). The contents of the JSON packet are specified in + * the Remote Debugging Protocol specification. + * + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + */ +function JSONPacket(transport) { + Packet.call(this, transport); + this._data = ""; + this._done = false; +} + +/** + * Attempt to initialize a new JSONPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {JSONPacket} + * Parsed packet, or null if it's not a match. + */ +JSONPacket.fromHeader = function(header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new JSONPacket(transport); + packet.length = +match[1]; + return packet; +}; + +JSONPacket.HEADER_PATTERN = /^(\d+):$/; + +JSONPacket.prototype = Object.create(Packet.prototype); + +Object.defineProperty(JSONPacket.prototype, "object", { + /** + * Gets the object (not the serialized string) being read or written. + */ + get() { + return this._object; + }, + + /** + * Sets the object to be sent when write() is called. + */ + set(object) { + this._object = object; + let data = JSON.stringify(object); + this._data = unicodeConverter.ConvertFromUnicode(data); + this.length = this._data.length; + }, +}); + +JSONPacket.prototype.read = function(stream, scriptableStream) { + + // Read in more packet data. + this._readData(stream, scriptableStream); + + if (!this.done) { + // Don't have a complete packet yet. + return; + } + + let json = this._data; + try { + json = unicodeConverter.ConvertToUnicode(json); + this._object = JSON.parse(json); + } catch (e) { + let msg = "Error parsing incoming packet: " + json + " (" + e + + " - " + e.stack + ")"; + console.error(msg); + dump(msg + "\n"); + return; + } + + this._transport._onJSONObjectReady(this._object); +}; + +JSONPacket.prototype._readData = function(stream, scriptableStream) { + let bytesToRead = Math.min( + this.length - this._data.length, + stream.available()); + this._data += scriptableStream.readBytes(bytesToRead); + this._done = this._data.length === this.length; +}; + +JSONPacket.prototype.write = function(stream) { + + if (this._outgoing === undefined) { + // Format the serialized packet to a buffer + this._outgoing = this.length + ":" + this._data; + } + + let written = stream.write(this._outgoing, this._outgoing.length); + this._outgoing = this._outgoing.slice(written); + this._done = !this._outgoing.length; +}; + +Object.defineProperty(JSONPacket.prototype, "done", { + get() { + return this._done; + }, +}); + +JSONPacket.prototype.toString = function() { + return JSON.stringify(this._object, null, 2); +}; + +/** + * With a bulk packet, data is transferred by temporarily handing over + * the transport's input or output stream to the application layer for + * writing data directly. This can be much faster for large data sets, + * and avoids various stages of copies and data duplication inherent in + * the JSON packet type. The bulk packet looks like: + * + * bulk [actor] [type] [length]:[data] + * + * The interpretation of the data portion depends on the kind of actor and + * the packet's type. See the Remote Debugging Protocol Stream Transport + * spec for more details. + * + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + */ +function BulkPacket(transport) { + Packet.call(this, transport); + this._done = false; + this._readyForWriting = defer(); +} + +/** + * Attempt to initialize a new BulkPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {BulkPacket} + * Parsed packet, or null if it's not a match. + */ +BulkPacket.fromHeader = function(header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new BulkPacket(transport); + packet.header = { + actor: match[1], + type: match[2], + length: +match[3], + }; + return packet; +}; + +BulkPacket.HEADER_PATTERN = /^bulk ([^: ]+) ([^: ]+) (\d+):$/; + +BulkPacket.prototype = Object.create(Packet.prototype); + +BulkPacket.prototype.read = function(stream) { + // Temporarily pause monitoring of the input stream + this._transport.pauseIncoming(); + + let deferred = defer(); + + this._transport._onBulkReadReady({ + actor: this.actor, + type: this.type, + length: this.length, + copyTo: (output) => { + let copying = StreamUtils.copyStream(stream, output, this.length); + deferred.resolve(copying); + return copying; + }, + stream, + done: deferred, + }); + + // Await the result of reading from the stream + deferred.promise.then(() => { + this._done = true; + this._transport.resumeIncoming(); + }, this._transport.close); + + // Ensure this is only done once + this.read = () => { + throw new Error("Tried to read() a BulkPacket's stream multiple times."); + }; +}; + +BulkPacket.prototype.write = function(stream) { + if (this._outgoingHeader === undefined) { + // Format the serialized packet header to a buffer + this._outgoingHeader = "bulk " + this.actor + " " + this.type + " " + + this.length + ":"; + } + + // Write the header, or whatever's left of it to write. + if (this._outgoingHeader.length) { + let written = stream.write(this._outgoingHeader, + this._outgoingHeader.length); + this._outgoingHeader = this._outgoingHeader.slice(written); + return; + } + + // Temporarily pause the monitoring of the output stream + this._transport.pauseOutgoing(); + + let deferred = defer(); + + this._readyForWriting.resolve({ + copyFrom: (input) => { + let copying = StreamUtils.copyStream(input, stream, this.length); + deferred.resolve(copying); + return copying; + }, + stream, + done: deferred, + }); + + // Await the result of writing to the stream + deferred.promise.then(() => { + this._done = true; + this._transport.resumeOutgoing(); + }, this._transport.close); + + // Ensure this is only done once + this.write = () => { + throw new Error("Tried to write() a BulkPacket's stream multiple times."); + }; +}; + +Object.defineProperty(BulkPacket.prototype, "streamReadyForWriting", { + get() { + return this._readyForWriting.promise; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "header", { + get() { + return { + actor: this.actor, + type: this.type, + length: this.length, + }; + }, + + set(header) { + this.actor = header.actor; + this.type = header.type; + this.length = header.length; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "done", { + get() { + return this._done; + }, +}); + +BulkPacket.prototype.toString = function() { + return "Bulk: " + JSON.stringify(this.header, null, 2); +}; + +/** + * RawPacket is used to test the transport's error handling of malformed + * packets, by writing data directly onto the stream. + * @param transport DebuggerTransport + * The transport instance that will own the packet. + * @param data string + * The raw string to send out onto the stream. + */ +function RawPacket(transport, data) { + Packet.call(this, transport); + this._data = data; + this.length = data.length; + this._done = false; +} + +RawPacket.prototype = Object.create(Packet.prototype); + +RawPacket.prototype.read = function() { + // this has not yet been needed for testing + throw new Error("Not implemented"); +}; + +RawPacket.prototype.write = function(stream) { + let written = stream.write(this._data, this._data.length); + this._data = this._data.slice(written); + this._done = !this._data.length; +}; + +Object.defineProperty(RawPacket.prototype, "done", { + get() { + return this._done; + }, +}); diff --git a/experimental/juggler/src/server/server.js b/experimental/juggler/src/server/server.js new file mode 100644 index 00000000..9ec7f871 --- /dev/null +++ b/experimental/juggler/src/server/server.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const CC = Components.Constructor; + +const ServerSocket = CC( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "initSpecialConnection"); + +ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const {DebuggerTransport} = ChromeUtils.import("chrome://juggler/content/server/transport.js", {}); + +const {KeepWhenOffline, LoopbackOnly} = Ci.nsIServerSocket; + +this.EXPORTED_SYMBOLS = [ + "TCPConnection", + "TCPListener", +]; + +class TCPListener { + constructor() { + this._socket = null; + this._nextConnID = 0; + this.onconnectioncreated = null; + this.onconnectionclosed = null; + } + + start(port) { + if (this._socket) + return; + try { + const flags = KeepWhenOffline | LoopbackOnly; + const backlog = 1; + this._socket = new ServerSocket(port, flags, backlog); + } catch (e) { + throw new Error(`Could not bind to port ${port} (${e.name})`); + } + this._socket.asyncListen(this); + return this._socket.port; + } + + stop() { + if (!this._socket) + return; + // Note that closing the server socket will not close currently active + // connections. + this._socket.close(); + this._socket = null; + } + + onSocketAccepted(serverSocket, clientSocket) { + const input = clientSocket.openInputStream(0, 0, 0); + const output = clientSocket.openOutputStream(0, 0, 0); + const transport = new DebuggerTransport(input, output); + + const connection = new TCPConnection(this._nextConnID++, transport, () => { + if (this.onconnectionclosed) + this.onconnectionclosed.call(null, connection); + }); + transport.ready(); + if (this.onconnectioncreated) + this.onconnectioncreated.call(null, connection); + } +} +this.TCPListener = TCPListener; + +class TCPConnection { + constructor(id, transport, closeCallback) { + this._id = id; + this._transport = transport; + // transport hooks are TCPConnection#onPacket + // and TCPConnection#onClosed + this._transport.hooks = this; + this._closeCallback = closeCallback; + this.onmessage = null; + } + + send(msg) { + this._transport.send(msg); + } + + onClosed() { + this._closeCallback.call(null); + } + + async onPacket(data) { + if (this.onmessage) + this.onmessage.call(null, data); + } +} +this.TCPConnection = TCPConnection; diff --git a/experimental/juggler/src/server/stream-utils.js b/experimental/juggler/src/server/stream-utils.js new file mode 100644 index 00000000..3d306823 --- /dev/null +++ b/experimental/juggler/src/server/stream-utils.js @@ -0,0 +1,247 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const CC = Components.Constructor; + +ChromeUtils.import("resource://gre/modules/EventEmitter.jsm"); +ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const IOUtil = Cc["@mozilla.org/io-util;1"].getService(Ci.nsIIOUtil); +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", "init"); + +this.EXPORTED_SYMBOLS = ["StreamUtils"]; + +const BUFFER_SIZE = 0x8000; + +/** + * This helper function (and its companion object) are used by bulk + * senders and receivers to read and write data in and out of other streams. + * Functions that make use of this tool are passed to callers when it is + * time to read or write bulk data. It is highly recommended to use these + * copier functions instead of the stream directly because the copier + * enforces the agreed upon length. Since bulk mode reuses an existing + * stream, the sender and receiver must write and read exactly the agreed + * upon amount of data, or else the entire transport will be left in a + * invalid state. Additionally, other methods of stream copying (such as + * NetUtil.asyncCopy) close the streams involved, which would terminate + * the debugging transport, and so it is avoided here. + * + * Overall, this *works*, but clearly the optimal solution would be + * able to just use the streams directly. If it were possible to fully + * implement nsIInputStream/nsIOutputStream in JS, wrapper streams could + * be created to enforce the length and avoid closing, and consumers could + * use familiar stream utilities like NetUtil.asyncCopy. + * + * The function takes two async streams and copies a precise number + * of bytes from one to the other. Copying begins immediately, but may + * complete at some future time depending on data size. Use the returned + * promise to know when it's complete. + * + * @param {nsIAsyncInputStream} input + * Stream to copy from. + * @param {nsIAsyncOutputStream} output + * Stream to copy to. + * @param {number} length + * Amount of data that needs to be copied. + * + * @return {Promise} + * Promise is resolved when copying completes or rejected if any + * (unexpected) errors occur. + */ +function copyStream(input, output, length) { + let copier = new StreamCopier(input, output, length); + return copier.copy(); +} + +/** @class */ +function StreamCopier(input, output, length) { + EventEmitter.decorate(this); + this._id = StreamCopier._nextId++; + this.input = input; + // Save off the base output stream, since we know it's async as we've + // required + this.baseAsyncOutput = output; + if (IOUtil.outputStreamIsBuffered(output)) { + this.output = output; + } else { + this.output = Cc["@mozilla.org/network/buffered-output-stream;1"] + .createInstance(Ci.nsIBufferedOutputStream); + this.output.init(output, BUFFER_SIZE); + } + this._length = length; + this._amountLeft = length; + this._deferred = { + promise: new Promise((resolve, reject) => { + this._deferred.resolve = resolve; + this._deferred.reject = reject; + }), + }; + + this._copy = this._copy.bind(this); + this._flush = this._flush.bind(this); + this._destroy = this._destroy.bind(this); + + // Copy promise's then method up to this object. + // + // Allows the copier to offer a promise interface for the simple succeed + // or fail scenarios, but also emit events (due to the EventEmitter) + // for other states, like progress. + this.then = this._deferred.promise.then.bind(this._deferred.promise); + this.then(this._destroy, this._destroy); + + // Stream ready callback starts as |_copy|, but may switch to |_flush| + // at end if flushing would block the output stream. + this._streamReadyCallback = this._copy; +} +StreamCopier._nextId = 0; + +StreamCopier.prototype = { + + copy() { + // Dispatch to the next tick so that it's possible to attach a progress + // event listener, even for extremely fast copies (like when testing). + Services.tm.currentThread.dispatch(() => { + try { + this._copy(); + } catch (e) { + this._deferred.reject(e); + } + }, 0); + return this; + }, + + _copy() { + let bytesAvailable = this.input.available(); + let amountToCopy = Math.min(bytesAvailable, this._amountLeft); + this._debug("Trying to copy: " + amountToCopy); + + let bytesCopied; + try { + bytesCopied = this.output.writeFrom(this.input, amountToCopy); + } catch (e) { + if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this._debug("Base stream would block, will retry"); + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + + this._amountLeft -= bytesCopied; + this._debug("Copied: " + bytesCopied + + ", Left: " + this._amountLeft); + this._emitProgress(); + + if (this._amountLeft === 0) { + this._debug("Copy done!"); + this._flush(); + return; + } + + this._debug("Waiting for input stream"); + this.input.asyncWait(this, 0, 0, Services.tm.currentThread); + }, + + _emitProgress() { + this.emit("progress", { + bytesSent: this._length - this._amountLeft, + totalBytes: this._length, + }); + }, + + _flush() { + try { + this.output.flush(); + } catch (e) { + if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK || + e.result == Cr.NS_ERROR_FAILURE) { + this._debug("Flush would block, will retry"); + this._streamReadyCallback = this._flush; + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + this._deferred.resolve(); + }, + + _destroy() { + this._destroy = null; + this._copy = null; + this._flush = null; + this.input = null; + this.output = null; + }, + + // nsIInputStreamCallback + onInputStreamReady() { + this._streamReadyCallback(); + }, + + // nsIOutputStreamCallback + onOutputStreamReady() { + this._streamReadyCallback(); + }, + + _debug() { + }, + +}; + +/** + * Read from a stream, one byte at a time, up to the next + * delimiter character, but stopping if we've read |count| + * without finding it. Reading also terminates early if there are less + * than count bytes available on the stream. In that case, + * we only read as many bytes as the stream currently has to offer. + * + * @param {nsIInputStream} stream + * Input stream to read from. + * @param {string} delimiter + * Character we're trying to find. + * @param {number} count + * Max number of characters to read while searching. + * + * @return {string} + * Collected data. If the delimiter was found, this string will + * end with it. + */ +// TODO: This implementation could be removed if bug 984651 is fixed, +// which provides a native version of the same idea. +function delimitedRead(stream, delimiter, count) { + let scriptableStream; + if (stream instanceof Ci.nsIScriptableInputStream) { + scriptableStream = stream; + } else { + scriptableStream = new ScriptableInputStream(stream); + } + + let data = ""; + + // Don't exceed what's available on the stream + count = Math.min(count, stream.available()); + + if (count <= 0) { + return data; + } + + let char; + while (char !== delimiter && count > 0) { + char = scriptableStream.readBytes(1); + count--; + data += char; + } + + return data; +} + +this.StreamUtils = { + copyStream, + delimitedRead, +}; diff --git a/experimental/juggler/src/server/transport.js b/experimental/juggler/src/server/transport.js new file mode 100644 index 00000000..8228fc36 --- /dev/null +++ b/experimental/juggler/src/server/transport.js @@ -0,0 +1,523 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global Pipe, ScriptableInputStream */ + +const CC = Components.Constructor; + +ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.import("resource://gre/modules/EventEmitter.jsm"); +const {StreamUtils} = + ChromeUtils.import("chrome://juggler/content/server/stream-utils.js", {}); +const {Packet, JSONPacket, BulkPacket} = + ChromeUtils.import("chrome://juggler/content/server/packets.js", {}); + +const executeSoon = function(func) { + Services.tm.dispatchToMainThread(func); +}; + +const flags = {wantVerbose: false, wantLogging: false}; + +const dumpv = + flags.wantVerbose ? + function(msg) { dump(msg + "\n"); } : + function() {}; + +const Pipe = CC("@mozilla.org/pipe;1", "nsIPipe", "init"); + +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", "init"); + +this.EXPORTED_SYMBOLS = ["DebuggerTransport"]; + +const PACKET_HEADER_MAX = 200; + +/** + * An adapter that handles data transfers between the debugger client + * and server. It can work with both nsIPipe and nsIServerSocket + * transports so long as the properly created input and output streams + * are specified. (However, for intra-process connections, + * LocalDebuggerTransport, below, is more efficient than using an nsIPipe + * pair with DebuggerTransport.) + * + * @param {nsIAsyncInputStream} input + * The input stream. + * @param {nsIAsyncOutputStream} output + * The output stream. + * + * Given a DebuggerTransport instance dt: + * 1) Set dt.hooks to a packet handler object (described below). + * 2) Call dt.ready() to begin watching for input packets. + * 3) Call dt.send() / dt.startBulkSend() to send packets. + * 4) Call dt.close() to close the connection, and disengage from + * the event loop. + * + * A packet handler is an object with the following methods: + * + * - onPacket(packet) - called when we have received a complete packet. + * |packet| is the parsed form of the packet --- a JavaScript value, not + * a JSON-syntax string. + * + * - onBulkPacket(packet) - called when we have switched to bulk packet + * receiving mode. |packet| is an object containing: + * * actor: Name of actor that will receive the packet + * * type: Name of actor's method that should be called on receipt + * * length: Size of the data to be read + * * stream: This input stream should only be used directly if you + * can ensure that you will read exactly |length| bytes and + * will not close the stream when reading is complete + * * done: If you use the stream directly (instead of |copyTo| + * below), you must signal completion by resolving/rejecting + * this deferred. If it's rejected, the transport will + * be closed. If an Error is supplied as a rejection value, + * it will be logged via |dump|. If you do use |copyTo|, + * resolving is taken care of for you when copying completes. + * * copyTo: A helper function for getting your data out of the + * stream that meets the stream handling requirements above, + * and has the following signature: + * + * @param nsIAsyncOutputStream {output} + * The stream to copy to. + * + * @return {Promise} + * The promise is resolved when copying completes or + * rejected if any (unexpected) errors occur. This object + * also emits "progress" events for each chunk that is + * copied. See stream-utils.js. + * + * - onClosed(reason) - called when the connection is closed. |reason| + * is an optional nsresult or object, typically passed when the + * transport is closed due to some error in a underlying stream. + * + * See ./packets.js and the Remote Debugging Protocol specification for + * more details on the format of these packets. + * + * @class + */ +function DebuggerTransport(input, output) { + EventEmitter.decorate(this); + + this._input = input; + this._scriptableInput = new ScriptableInputStream(input); + this._output = output; + + // The current incoming (possibly partial) header, which will determine + // which type of Packet |_incoming| below will become. + this._incomingHeader = ""; + // The current incoming Packet object + this._incoming = null; + // A queue of outgoing Packet objects + this._outgoing = []; + + this.hooks = null; + this.active = false; + + this._incomingEnabled = true; + this._outgoingEnabled = true; + + this.close = this.close.bind(this); +} + +DebuggerTransport.prototype = { + /** + * Transmit an object as a JSON packet. + * + * This method returns immediately, without waiting for the entire + * packet to be transmitted, registering event handlers as needed to + * transmit the entire packet. Packets are transmitted in the order they + * are passed to this method. + */ + send(object) { + this.emit("send", object); + + let packet = new JSONPacket(this); + packet.object = object; + this._outgoing.push(packet); + this._flushOutgoing(); + }, + + /** + * Transmit streaming data via a bulk packet. + * + * This method initiates the bulk send process by queuing up the header + * data. The caller receives eventual access to a stream for writing. + * + * N.B.: Do *not* attempt to close the stream handed to you, as it + * will continue to be used by this transport afterwards. Most users + * should instead use the provided |copyFrom| function instead. + * + * @param {Object} header + * This is modeled after the format of JSON packets above, but does + * not actually contain the data, but is instead just a routing + * header: + * + * - actor: Name of actor that will receive the packet + * - type: Name of actor's method that should be called on receipt + * - length: Size of the data to be sent + * + * @return {Promise} + * The promise will be resolved when you are allowed to write to + * the stream with an object containing: + * + * - stream: This output stream should only be used directly + * if you can ensure that you will write exactly + * |length| bytes and will not close the stream when + * writing is complete. + * - done: If you use the stream directly (instead of + * |copyFrom| below), you must signal completion by + * resolving/rejecting this deferred. If it's + * rejected, the transport will be closed. If an + * Error is supplied as a rejection value, it will + * be logged via |dump|. If you do use |copyFrom|, + * resolving is taken care of for you when copying + * completes. + * - copyFrom: A helper function for getting your data onto the + * stream that meets the stream handling requirements + * above, and has the following signature: + * + * @param {nsIAsyncInputStream} input + * The stream to copy from. + * + * @return {Promise} + * The promise is resolved when copying completes + * or rejected if any (unexpected) errors occur. + * This object also emits "progress" events for + * each chunkthat is copied. See stream-utils.js. + */ + startBulkSend(header) { + this.emit("startbulksend", header); + + let packet = new BulkPacket(this); + packet.header = header; + this._outgoing.push(packet); + this._flushOutgoing(); + return packet.streamReadyForWriting; + }, + + /** + * Close the transport. + * + * @param {(nsresult|object)=} reason + * The status code or error message that corresponds to the reason + * for closing the transport (likely because a stream closed + * or failed). + */ + close(reason) { + this.emit("close", reason); + + this.active = false; + this._input.close(); + this._scriptableInput.close(); + this._output.close(); + this._destroyIncoming(); + this._destroyAllOutgoing(); + if (this.hooks) { + this.hooks.onClosed(reason); + this.hooks = null; + } + if (reason) { + dumpv("Transport closed: " + reason); + } else { + dumpv("Transport closed."); + } + }, + + /** + * The currently outgoing packet (at the top of the queue). + */ + get _currentOutgoing() { + return this._outgoing[0]; + }, + + /** + * Flush data to the outgoing stream. Waits until the output + * stream notifies us that it is ready to be written to (via + * onOutputStreamReady). + */ + _flushOutgoing() { + if (!this._outgoingEnabled || this._outgoing.length === 0) { + return; + } + + // If the top of the packet queue has nothing more to send, remove it. + if (this._currentOutgoing.done) { + this._finishCurrentOutgoing(); + } + + if (this._outgoing.length > 0) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._output.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to write to the output stream. + * This is used when we've temporarily handed off our output stream for + * writing bulk data. + */ + pauseOutgoing() { + this._outgoingEnabled = false; + }, + + /** + * Resume this transport's attempts to write to the output stream. + */ + resumeOutgoing() { + this._outgoingEnabled = true; + this._flushOutgoing(); + }, + + // nsIOutputStreamCallback + /** + * This is called when the output stream is ready for more data to + * be written. The current outgoing packet will attempt to write some + * amount of data, but may not complete. + */ + onOutputStreamReady(stream) { + if (!this._outgoingEnabled || this._outgoing.length === 0) { + return; + } + + try { + this._currentOutgoing.write(stream); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + return; + } + throw e; + } + + this._flushOutgoing(); + }, + + /** + * Remove the current outgoing packet from the queue upon completion. + */ + _finishCurrentOutgoing() { + if (this._currentOutgoing) { + this._currentOutgoing.destroy(); + this._outgoing.shift(); + } + }, + + /** + * Clear the entire outgoing queue. + */ + _destroyAllOutgoing() { + for (let packet of this._outgoing) { + packet.destroy(); + } + this._outgoing = []; + }, + + /** + * Initialize the input stream for reading. Once this method has been + * called, we watch for packets on the input stream, and pass them to + * the appropriate handlers via this.hooks. + */ + ready() { + this.active = true; + this._waitForIncoming(); + }, + + /** + * Asks the input stream to notify us (via onInputStreamReady) when it is + * ready for reading. + */ + _waitForIncoming() { + if (this._incomingEnabled) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._input.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to read from the input stream. + * This is used when we've temporarily handed off our input stream for + * reading bulk data. + */ + pauseIncoming() { + this._incomingEnabled = false; + }, + + /** + * Resume this transport's attempts to read from the input stream. + */ + resumeIncoming() { + this._incomingEnabled = true; + this._flushIncoming(); + this._waitForIncoming(); + }, + + // nsIInputStreamCallback + /** + * Called when the stream is either readable or closed. + */ + onInputStreamReady(stream) { + try { + while (stream.available() && this._incomingEnabled && + this._processIncoming(stream, stream.available())) { + // Loop until there is nothing more to process + } + this._waitForIncoming(); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + } else { + throw e; + } + } + }, + + /** + * Process the incoming data. Will create a new currently incoming + * Packet if needed. Tells the incoming Packet to read as much data + * as it can, but reading may not complete. The Packet signals that + * its data is ready for delivery by calling one of this transport's + * _on*Ready methods (see ./packets.js and the _on*Ready methods below). + * + * @return {boolean} + * Whether incoming stream processing should continue for any + * remaining data. + */ + _processIncoming(stream, count) { + dumpv("Data available: " + count); + + if (!count) { + dumpv("Nothing to read, skipping"); + return false; + } + + try { + if (!this._incoming) { + dumpv("Creating a new packet from incoming"); + + if (!this._readHeader(stream)) { + // Not enough data to read packet type + return false; + } + + // Attempt to create a new Packet by trying to parse each possible + // header pattern. + this._incoming = Packet.fromHeader(this._incomingHeader, this); + if (!this._incoming) { + throw new Error("No packet types for header: " + + this._incomingHeader); + } + } + + if (!this._incoming.done) { + // We have an incomplete packet, keep reading it. + dumpv("Existing packet incomplete, keep reading"); + this._incoming.read(stream, this._scriptableInput); + } + } catch (e) { + dump(`Error reading incoming packet: (${e} - ${e.stack})\n`); + + // Now in an invalid state, shut down the transport. + this.close(); + return false; + } + + if (!this._incoming.done) { + // Still not complete, we'll wait for more data. + dumpv("Packet not done, wait for more"); + return true; + } + + // Ready for next packet + this._flushIncoming(); + return true; + }, + + /** + * Read as far as we can into the incoming data, attempting to build + * up a complete packet header (which terminates with ":"). We'll only + * read up to PACKET_HEADER_MAX characters. + * + * @return {boolean} + * True if we now have a complete header. + */ + _readHeader() { + let amountToRead = PACKET_HEADER_MAX - this._incomingHeader.length; + this._incomingHeader += + StreamUtils.delimitedRead(this._scriptableInput, ":", amountToRead); + if (flags.wantVerbose) { + dumpv("Header read: " + this._incomingHeader); + } + + if (this._incomingHeader.endsWith(":")) { + if (flags.wantVerbose) { + dumpv("Found packet header successfully: " + this._incomingHeader); + } + return true; + } + + if (this._incomingHeader.length >= PACKET_HEADER_MAX) { + throw new Error("Failed to parse packet header!"); + } + + // Not enough data yet. + return false; + }, + + /** + * If the incoming packet is done, log it as needed and clear the buffer. + */ + _flushIncoming() { + if (!this._incoming.done) { + return; + } + if (flags.wantLogging) { + dumpv("Got: " + this._incoming); + } + this._destroyIncoming(); + }, + + /** + * Handler triggered by an incoming JSONPacket completing it's |read| + * method. Delivers the packet to this.hooks.onPacket. + */ + _onJSONObjectReady(object) { + executeSoon(() => { + // Ensure the transport is still alive by the time this runs. + if (this.active) { + this.emit("packet", object); + this.hooks.onPacket(object); + } + }); + }, + + /** + * Handler triggered by an incoming BulkPacket entering the |read| + * phase for the stream portion of the packet. Delivers info about the + * incoming streaming data to this.hooks.onBulkPacket. See the main + * comment on the transport at the top of this file for more details. + */ + _onBulkReadReady(...args) { + executeSoon(() => { + // Ensure the transport is still alive by the time this runs. + if (this.active) { + this.emit("bulkpacket", ...args); + this.hooks.onBulkPacket(...args); + } + }); + }, + + /** + * Remove all handlers and references related to the current incoming + * packet, either because it is now complete or because the transport + * is closing. + */ + _destroyIncoming() { + if (this._incoming) { + this._incoming.destroy(); + } + this._incomingHeader = ""; + this._incoming = null; + }, +}; diff --git a/experimental/puppeteer-firefox/.ci/node6/Dockerfile.linux b/experimental/puppeteer-firefox/.ci/node6/Dockerfile.linux new file mode 100644 index 00000000..77b41673 --- /dev/null +++ b/experimental/puppeteer-firefox/.ci/node6/Dockerfile.linux @@ -0,0 +1,17 @@ +FROM node:6.12.3 + +RUN apt-get update && \ + apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \ + libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \ + libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \ + libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \ + rm -rf /var/lib/apt/lists/* + +# Add user so we don't need --no-sandbox. +RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ + && mkdir -p /home/pptruser/Downloads \ + && chown -R pptruser:pptruser /home/pptruser + +# Run everything after as non-privileged user. +USER pptruser diff --git a/experimental/puppeteer-firefox/.ci/node8/Dockerfile.linux b/experimental/puppeteer-firefox/.ci/node8/Dockerfile.linux new file mode 100644 index 00000000..bb4da3af --- /dev/null +++ b/experimental/puppeteer-firefox/.ci/node8/Dockerfile.linux @@ -0,0 +1,17 @@ +FROM node:8.11.3-stretch + +RUN apt-get update && \ + apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \ + libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \ + libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \ + libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \ + rm -rf /var/lib/apt/lists/* + +# Add user so we don't need --no-sandbox. +RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ + && mkdir -p /home/pptruser/Downloads \ + && chown -R pptruser:pptruser /home/pptruser + +# Run everything after as non-privileged user. +USER pptruser diff --git a/experimental/puppeteer-firefox/.ci/node8/Dockerfile.windows b/experimental/puppeteer-firefox/.ci/node8/Dockerfile.windows new file mode 100644 index 00000000..d931548d --- /dev/null +++ b/experimental/puppeteer-firefox/.ci/node8/Dockerfile.windows @@ -0,0 +1,11 @@ +FROM microsoft/windowsservercore:latest + +ENV NODE_VERSION 8.11.3 + +RUN setx /m PATH "%PATH%;C:\nodejs" + +RUN powershell -Command \ + netsh interface ipv4 set subinterface 18 mtu=1460 store=persistent ; \ + Invoke-WebRequest $('https://nodejs.org/dist/v{0}/node-v{0}-win-x64.zip' -f $env:NODE_VERSION) -OutFile 'node.zip' -UseBasicParsing ; \ + Expand-Archive node.zip -DestinationPath C:\ ; \ + Rename-Item -Path $('C:\node-v{0}-win-x64' -f $env:NODE_VERSION) -NewName 'C:\nodejs' diff --git a/experimental/puppeteer-firefox/.cirrus.yml b/experimental/puppeteer-firefox/.cirrus.yml new file mode 100644 index 00000000..9a6c162a --- /dev/null +++ b/experimental/puppeteer-firefox/.cirrus.yml @@ -0,0 +1,31 @@ +env: + DISPLAY: :99.0 + +task: + name: node8 (linux) + container: + dockerfile: .ci/node8/Dockerfile.linux + xvfb_start_background_script: Xvfb :99 -ac -screen 0 1024x768x24 + install_script: npm install + test_script: npm run funit + +task: + name: node8 (macOS) + osx_instance: + image: high-sierra-base + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + node_install_script: + - brew install node@8 + - brew link --force node@8 + install_script: npm install + test_script: npm run funit + +# task: +# allow_failures: true +# windows_container: +# dockerfile: .ci/node8/Dockerfile.windows +# os_version: 2016 +# name: node8 (windows) +# install_script: npm install --unsafe-perm +# test_script: npm run funit diff --git a/experimental/puppeteer-firefox/.gitignore b/experimental/puppeteer-firefox/.gitignore new file mode 100644 index 00000000..e8dbe443 --- /dev/null +++ b/experimental/puppeteer-firefox/.gitignore @@ -0,0 +1,10 @@ +/node_modules/ +.DS_Store +*.swp +*.pyc +.vscode +package-lock.json +yarn.lock +.local-browser +/test/output-chromium +/test/output-firefox diff --git a/experimental/puppeteer-firefox/.npmignore b/experimental/puppeteer-firefox/.npmignore new file mode 100644 index 00000000..11e76f2e --- /dev/null +++ b/experimental/puppeteer-firefox/.npmignore @@ -0,0 +1,37 @@ +# exclude all tests +test +utils/node6-transform + +# exclude internal type definition files +/lib/*.d.ts +/node6/lib/*.d.ts + +# repeats from .gitignore +node_modules +.local-chromium +.local-browser +.dev_profile* +.DS_Store +*.swp +*.pyc +.vscode +package-lock.json +/node6/test +/node6/utils +/test +/utils +/docs +yarn.lock + +# other +/.ci +/examples +.appveyour.yml +.cirrus.yml +.editorconfig +.eslintignore +.eslintrc.js +.travis.yml +README.md +tsconfig.json + diff --git a/experimental/puppeteer-firefox/Errors.js b/experimental/puppeteer-firefox/Errors.js new file mode 100644 index 00000000..94d6d412 --- /dev/null +++ b/experimental/puppeteer-firefox/Errors.js @@ -0,0 +1 @@ +module.exports = require('./lib/Errors'); diff --git a/experimental/puppeteer-firefox/LICENSE b/experimental/puppeteer-firefox/LICENSE new file mode 100644 index 00000000..afdfe50e --- /dev/null +++ b/experimental/puppeteer-firefox/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/experimental/puppeteer-firefox/README.md b/experimental/puppeteer-firefox/README.md new file mode 100644 index 00000000..2eb8b71f --- /dev/null +++ b/experimental/puppeteer-firefox/README.md @@ -0,0 +1,187 @@ +# Puppeteer-Firefox + +> Puppeteer-Firefox - Puppeteer API for Firefox + +> **BEWARE**: This project is experimental. Alligators live here. + +## Getting Started + +### Installation + +To use Puppeteer-Firefox in your project, run: + +```bash +npm i puppeteer-firefox +# or "yarn add puppeteer-firefox" +``` + +Note: When you install Puppeteer-Firefox, it downloads a [custom-built Firefox](https://github.com/GoogleChrome/puppeteer/tree/master/experimental/juggler) that is guaranteed to work with the API. + +### Usage + +**Example** - navigating to https://example.com and saving a screenshot as *example.png*: + +Save file as **example.js** + +```js +const pptrFirefox = require('puppeteer-firefox'); + +(async () => { + const browser = await pptrFirefox.launch(); + const page = await browser.newPage(); + await page.goto('https://example.com'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); +``` + +Execute script on the command line + +```bash +node example.js +``` + + +### API Status + +- class: Puppeteer + * puppeteer.executablePath() + * puppeteer.launch([options]) +- class: Browser + * event: 'targetchanged' + * event: 'targetcreated' + * event: 'targetdestroyed' + * browser.close() + * browser.newPage() + * browser.pages() + * browser.process() + * browser.targets() + * browser.userAgent() + * browser.version() + * browser.waitForTarget(predicate[, options]) +- class: Target + * target.browser() + * target.page() + * target.type() + * target.url() +- class: Page + * event: 'close' + * event: 'console' + * event: 'dialog' + * event: 'domcontentloaded' + * event: 'frameattached' + * event: 'framedetached' + * event: 'framenavigated' + * event: 'load' + * event: 'pageerror' + * page.$(selector) + * page.$$(selector) + * page.$$eval(selector, pageFunction[, ...args]) + * page.$eval(selector, pageFunction[, ...args]) + * page.$x(expression) + * page.addScriptTag(options) + * page.addStyleTag(options) + * page.browser() + * page.click(selector[, options]) + * page.close(options) + * page.content() + * page.evaluate(pageFunction, ...args) + * page.evaluateOnNewDocument(pageFunction, ...args) + * page.focus(selector) + * page.frames() + * page.goBack(options) + * page.goForward(options) + * page.goto(url, options) + * page.hover(selector) + * page.isClosed() + * page.keyboard + * page.mainFrame() + * page.mouse + * page.reload(options) + * page.screenshot([options]) + * page.select(selector, ...values) + * page.setContent(html) + * page.setViewport(viewport) + * page.target() + * page.title() + * page.type(selector, text[, options]) + * page.url() + * page.viewport() + * page.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]]) + * page.waitForFunction(pageFunction[, options[, ...args]]) + * page.waitForNavigation(options) + * page.waitForSelector(selector[, options]) + * page.waitForXPath(xpath[, options]) +- class: Frame + * frame.$(selector) + * frame.$$(selector) + * frame.$$eval(selector, pageFunction[, ...args]) + * frame.$eval(selector, pageFunction[, ...args]) + * frame.$x(expression) + * frame.addScriptTag(options) + * frame.addStyleTag(options) + * frame.childFrames() + * frame.click(selector[, options]) + * frame.content() + * frame.evaluate(pageFunction, ...args) + * frame.focus(selector) + * frame.hover(selector) + * frame.isDetached() + * frame.name() + * frame.parentFrame() + * frame.select(selector, ...values) + * frame.setContent(html) + * frame.title() + * frame.type(selector, text[, options]) + * frame.url() + * frame.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]]) + * frame.waitForFunction(pageFunction[, options[, ...args]]) + * frame.waitForSelector(selector[, options]) + * frame.waitForXPath(xpath[, options]) +- class: JSHandle + * jsHandle.asElement() + * jsHandle.dispose() + * jsHandle.getProperties() + * jsHandle.getProperty(propertyName) + * jsHandle.jsonValue() + * jsHandle.toString() +- class: ElementHandle + * elementHandle.$(selector) + * elementHandle.$$(selector) + * elementHandle.$$eval(selector, pageFunction, ...args) + * elementHandle.$eval(selector, pageFunction, ...args) + * elementHandle.$x(expression) + * elementHandle.boundingBox() + * elementHandle.click([options]) + * elementHandle.dispose() + * elementHandle.focus() + * elementHandle.hover() + * elementHandle.isIntersectingViewport() + * elementHandle.press(key[, options]) + * elementHandle.screenshot([options]) + * elementHandle.type(text[, options]) +- class: Keyboard + * keyboard.down(key[, options]) + * keyboard.press(key[, options]) + * keyboard.sendCharacter(char) + * keyboard.type(text, options) + * keyboard.up(key) +- class: Mouse + * mouse.click(x, y, [options]) + * mouse.down([options]) + * mouse.move(x, y, [options]) + * mouse.up([options]) +- class: Dialog + * dialog.accept([promptText]) + * dialog.defaultValue() + * dialog.dismiss() + * dialog.message() + * dialog.type() +- class: ConsoleMessage + * consoleMessage.args() + * consoleMessage.text() + * consoleMessage.type() +- class: TimeoutError + + +Special thanks to [Amine Zaza](https://bitbucket.org/aminerop/) who volunteered the [`puppeteer-firefox`](https://www.npmjs.com/package/puppeteer-firefox) NPM package. diff --git a/experimental/puppeteer-firefox/completeness.sh b/experimental/puppeteer-firefox/completeness.sh new file mode 100755 index 00000000..1a3a59cb --- /dev/null +++ b/experimental/puppeteer-firefox/completeness.sh @@ -0,0 +1,7 @@ +set -e + +total=`git grep ' \* \[' README.md| wc -l` +complete=`git grep ' \* \[x' README.md | wc -l` +ratio=`echo "$complete / $total * 100" | bc -l` +printf "%.2f%%\n" $ratio + diff --git a/experimental/puppeteer-firefox/examples/screenshot.js b/experimental/puppeteer-firefox/examples/screenshot.js new file mode 100644 index 00000000..09d3ccfc --- /dev/null +++ b/experimental/puppeteer-firefox/examples/screenshot.js @@ -0,0 +1,27 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const puppeteer = require('puppeteer-firefox'); + +(async() => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.screenshot({path: 'example.png'}); + await browser.close(); +})(); diff --git a/experimental/puppeteer-firefox/examples/search.js b/experimental/puppeteer-firefox/examples/search.js new file mode 100644 index 00000000..109512c8 --- /dev/null +++ b/experimental/puppeteer-firefox/examples/search.js @@ -0,0 +1,55 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Search developers.google.com/web for articles tagged + * "Headless Chrome" and scrape results from the results page. + */ + +'use strict'; + +const puppeteer = require('puppeteer-firefox'); + +(async() => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + await page.goto('https://developers.google.com/web/'); + + // Type into search box. + await page.type('#searchbox input', 'Headless Chrome'); + + // Wait for suggest overlay to appear and click "show all results". + const allResultsSelector = '.devsite-suggest-all-results'; + await page.waitForSelector(allResultsSelector); + await page.click(allResultsSelector); + + // Wait for the results page to load and display the results. + const resultsSelector = '.gsc-results .gsc-thumbnail-inside a.gs-title'; + await page.waitForSelector(resultsSelector); + + // Extract the results from the page. + const links = await page.evaluate(resultsSelector => { + const anchors = Array.from(document.querySelectorAll(resultsSelector)); + return anchors.map(anchor => { + const title = anchor.textContent.split('|')[0].trim(); + return `${title} - ${anchor.href}`; + }); + }, resultsSelector); + console.log(links.join('\n')); + + await browser.close(); +})(); diff --git a/experimental/puppeteer-firefox/index.js b/experimental/puppeteer-firefox/index.js new file mode 100644 index 00000000..6bbce931 --- /dev/null +++ b/experimental/puppeteer-firefox/index.js @@ -0,0 +1,56 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const FirefoxLauncher = require('./lib/firefox/Launcher.js').Launcher; +const BrowserFetcher = require('./lib/firefox/BrowserFetcher.js'); + +class Puppeteer { + constructor() { + this._firefoxLauncher = new FirefoxLauncher(); + } + + async launch(options = {}) { + const { + args = [], + dumpio = !!process.env.DUMPIO, + handleSIGHUP = true, + handleSIGINT = true, + handleSIGTERM = true, + headless = (process.env.HEADLESS || 'true').trim().toLowerCase() === 'true', + defaultViewport = {width: 800, height: 600}, + ignoreHTTPSErrors = false, + slowMo = 0, + executablePath = this.executablePath(), + } = options; + options = { + args, slowMo, dumpio, executablePath, handleSIGHUP, handleSIGINT, handleSIGTERM, headless, defaultViewport, + ignoreHTTPSErrors + }; + return await this._firefoxLauncher.launch(options); + } + + createBrowserFetcher(options) { + return new BrowserFetcher(__dirname, options); + } + + executablePath() { + const browserFetcher = new BrowserFetcher(__dirname, { product: 'firefox' }); + const revision = require('./package.json').puppeteer.firefox_revision; + const revisionInfo = browserFetcher.revisionInfo(revision); + return revisionInfo.executablePath; + } +} + +module.exports = new Puppeteer(); diff --git a/experimental/puppeteer-firefox/install.js b/experimental/puppeteer-firefox/install.js new file mode 100644 index 00000000..cd2807c7 --- /dev/null +++ b/experimental/puppeteer-firefox/install.js @@ -0,0 +1,153 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +// puppeteer-core should not install anything. +if (require('./package.json').name === 'puppeteer-core') + return; + +const downloadHost = process.env.PUPPETEER_DOWNLOAD_HOST || process.env.npm_config_puppeteer_download_host || process.env.npm_package_config_puppeteer_download_host; + +const puppeteer = require('./index'); +const browserFetcher = puppeteer.createBrowserFetcher({ host: downloadHost, product: 'firefox' }); + +const revision = require('./package.json').puppeteer.firefox_revision; + +const revisionInfo = browserFetcher.revisionInfo(revision); + +// Do nothing if the revision is already downloaded. +if (revisionInfo.local) + return; + +// Override current environment proxy settings with npm configuration, if any. +const NPM_HTTPS_PROXY = process.env.npm_config_https_proxy || process.env.npm_config_proxy; +const NPM_HTTP_PROXY = process.env.npm_config_http_proxy || process.env.npm_config_proxy; +const NPM_NO_PROXY = process.env.npm_config_no_proxy; + +if (NPM_HTTPS_PROXY) + process.env.HTTPS_PROXY = NPM_HTTPS_PROXY; +if (NPM_HTTP_PROXY) + process.env.HTTP_PROXY = NPM_HTTP_PROXY; +if (NPM_NO_PROXY) + process.env.NO_PROXY = NPM_NO_PROXY; + +browserFetcher.download(revisionInfo.revision, onProgress) + .then(() => browserFetcher.localRevisions()) + .then(onSuccess) + .catch(onError); + +/** + * @param {!Array} + * @return {!Promise} + */ +function onSuccess(localRevisions) { + console.log('Firefox downloaded to ' + revisionInfo.folderPath); + localRevisions = localRevisions.filter(revision => revision !== revisionInfo.revision); + // Remove previous firefox revisions. + const cleanupOldVersions = localRevisions.map(revision => browserFetcher.remove(revision)); + return Promise.all([...cleanupOldVersions, installFirefoxPreferences()]); +} + +/** + * @param {!Error} error + */ +function onError(error) { + console.error(`ERROR: Failed to download Firefox r${revision}!`); + console.error(error); + process.exit(1); +} + +let progressBar = null; +let lastDownloadedBytes = 0; +function onProgress(downloadedBytes, totalBytes) { + if (!progressBar) { + const ProgressBar = require('progress'); + progressBar = new ProgressBar(`Downloading Firefox+Puppeteer ${revision.substring(0, 8)} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, { + complete: '|', + incomplete: ' ', + width: 20, + total: totalBytes, + }); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); +} + +function toMegabytes(bytes) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; +} + +// Install browser preferences after downloading and unpacking +// firefox instances. +// Based on: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Enterprise_deployment_before_60#Configuration +async function installFirefoxPreferences() { + const revisionInfo = browserFetcher.revisionInfo(revision); + const firefoxFolder = path.dirname(revisionInfo.executablePath); + const {helper} = require('./lib/firefox/helper'); + const mkdirAsync = helper.promisify(fs.mkdir.bind(fs)); + + let prefPath = ''; + let configPath = ''; + if (os.platform() === 'darwin') { + prefPath = path.join(firefoxFolder, '..', 'Resources', 'defaults', 'pref'); + configPath = path.join(firefoxFolder, '..', 'Resources'); + } else if (os.platform() === 'linux') { + await mkdirAsync(path.join(firefoxFolder, 'browser', 'defaults')); + await mkdirAsync(path.join(firefoxFolder, 'browser', 'defaults', 'preferences')); + prefPath = path.join(firefoxFolder, 'browser', 'defaults', 'preferences'); + configPath = firefoxFolder; + } else if (os.platform() === 'win32') { + prefPath = path.join(firefoxFolder, 'defaults', 'pref'); + configPath = firefoxFolder; + } else { + throw new Error('Unsupported platform: ' + os.platform()); + } + + await Promise.all([ + copyFile({ + from: path.join(__dirname, 'misc', '00-puppeteer-prefs.js'), + to: path.join(prefPath, '00-puppeteer-prefs.js'), + }), + copyFile({ + from: path.join(__dirname, 'misc', 'puppeteer.cfg'), + to: path.join(configPath, 'puppeteer.cfg'), + }), + ]).then(() => { + console.log('Firefox preferences installed!'); + }); +} + +function copyFile({from, to}) { + var rd = fs.createReadStream(from); + var wr = fs.createWriteStream(to); + return new Promise(function(resolve, reject) { + rd.on('error', reject); + wr.on('error', reject); + wr.on('finish', resolve); + rd.pipe(wr); + }).catch(function(error) { + rd.destroy(); + wr.end(); + throw error; + }); +} + diff --git a/experimental/puppeteer-firefox/lib/Errors.js b/experimental/puppeteer-firefox/lib/Errors.js new file mode 100644 index 00000000..1bcfc2d0 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/Errors.js @@ -0,0 +1,29 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class CustomError extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +class TimeoutError extends CustomError {} + +module.exports = { + TimeoutError, +}; diff --git a/experimental/puppeteer-firefox/lib/common.js b/experimental/puppeteer-firefox/lib/common.js new file mode 100644 index 00000000..0614aa61 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/common.js @@ -0,0 +1,20 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const constants = { + DEFAULT_NAVIGATION_TIMEOUT: 30000, +}; + +module.exports = {constants}; diff --git a/experimental/puppeteer-firefox/lib/firefox/Browser.js b/experimental/puppeteer-firefox/lib/firefox/Browser.js new file mode 100644 index 00000000..aaae4523 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Browser.js @@ -0,0 +1,171 @@ +const {helper} = require('./helper'); +const {Page} = require('./Page'); +const EventEmitter = require('events'); + +class Browser extends EventEmitter { + /** + * @param {!Puppeteer.Connection} connection + * @param {?Puppeteer.Viewport} defaultViewport + * @param {?Puppeteer.ChildProcess} process + * @param {function():void} closeCallback + */ + constructor(connection, defaultViewport, process, closeCallback) { + super(); + this._connection = connection; + this._defaultViewport = defaultViewport; + this._process = process; + this._closeCallback = closeCallback; + + /** @type {!Map} */ + this._pageTargets = new Map(); + + this._eventListeners = [ + helper.addEventListener(this._connection, 'Browser.tabOpened', this._onTabOpened.bind(this)), + helper.addEventListener(this._connection, 'Browser.tabClosed', this._onTabClosed.bind(this)), + helper.addEventListener(this._connection, 'Browser.tabNavigated', this._onTabNavigated.bind(this)), + ]; + } + + /** + * @return {!Promise} + */ + async userAgent() { + const info = await this._connection.send('Browser.getInfo'); + return info.userAgent; + } + + /** + * @return {!Promise} + */ + async version() { + const info = await this._connection.send('Browser.getInfo'); + return info.version; + } + + /** + * @return {?Puppeteer.ChildProcess} + */ + process() { + return this._process; + } + + /** + * @param {function(!Target):boolean} predicate + * @param {{timeout?: number}=} options + * @return {!Promise} + */ + async waitForTarget(predicate, options = {}) { + const { + timeout = 30000 + } = options; + const existingTarget = this.targets().find(predicate); + if (existingTarget) + return existingTarget; + let resolve; + const targetPromise = new Promise(x => resolve = x); + this.on(Browser.Events.TargetCreated, check); + this.on('targetchanged', check); + try { + if (!timeout) + return await targetPromise; + return await helper.waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this.removeListener(Browser.Events.TargetCreated, check); + this.removeListener('targetchanged', check); + } + + /** + * @param {!Target} target + */ + function check(target) { + if (predicate(target)) + resolve(target); + } + } + + async newPage() { + const {pageId} = await this._connection.send('Browser.newPage'); + const target = this._pageTargets.get(pageId); + return await target.page(); + } + + async pages() { + const pageTargets = Array.from(this._pageTargets.values()); + return await Promise.all(pageTargets.map(target => target.page())); + } + + targets() { + return Array.from(this._pageTargets.values()); + } + + _onTabOpened({pageId, url}) { + const target = new Target(this._connection, this, pageId, url); + this._pageTargets.set(pageId, target); + this.emit(Browser.Events.TargetCreated, target); + } + + _onTabClosed({pageId}) { + const target = this._pageTargets.get(pageId); + this._pageTargets.delete(pageId); + this.emit(Browser.Events.TargetDestroyed, target); + } + + _onTabNavigated({pageId, url}) { + const target = this._pageTargets.get(pageId); + target._url = url; + this.emit(Browser.Events.TargetChanged, target); + } + + async close() { + helper.removeEventListeners(this._eventListeners); + await this._closeCallback(); + } +} + +/** @enum {string} */ +Browser.Events = { + TargetCreated: 'targetcreated', + TargetChanged: 'targetchanged', + TargetDestroyed: 'targetdestroyed' +} + +class Target { + /** + * + * @param {*} connection + * @param {!Browser} browser + * @param {string} pageId + * @param {string} url + */ + constructor(connection, browser, pageId, url) { + this._browser = browser; + this._connection = connection; + this._pageId = pageId; + /** @type {?Promise} */ + this._pagePromise = null; + this._url = url; + } + + /** + * @return {"page"|"background_page"|"service_worker"|"other"|"browser"} + */ + type() { + return 'page'; + } + + url() { + return this._url; + } + + async page() { + if (!this._pagePromise) + this._pagePromise = Page.create(this._connection, this, this._pageId, this._browser._defaultViewport); + return this._pagePromise; + } + + browser() { + return this._browser; + } +} + +module.exports = {Browser, Target}; diff --git a/experimental/puppeteer-firefox/lib/firefox/BrowserFetcher.js b/experimental/puppeteer-firefox/lib/firefox/BrowserFetcher.js new file mode 100644 index 00000000..b9c5eafe --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/BrowserFetcher.js @@ -0,0 +1,342 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const extract = require('extract-zip'); +const util = require('util'); +const URL = require('url'); +const {helper, assert} = require('./helper'); +const removeRecursive = require('rimraf'); +// @ts-ignore +const ProxyAgent = require('https-proxy-agent'); +// @ts-ignore +const getProxyForUrl = require('proxy-from-env').getProxyForUrl; + +const DEFAULT_DOWNLOAD_HOST = 'https://storage.googleapis.com'; + +const downloadURLs = { + chromium: { + linux: '%s/chromium-browser-snapshots/Linux_x64/%s/%s.zip', + mac: '%s/chromium-browser-snapshots/Mac/%s/%s.zip', + win32: '%s/chromium-browser-snapshots/Win/%s/%s.zip', + win64: '%s/chromium-browser-snapshots/Win_x64/%s/%s.zip', + }, + firefox: { + linux: '%s/juggler-builds/%s/%s.zip', + mac: '%s/juggler-builds/%s/%s.zip', + win32: '%s/juggler-builds/%s/%s.zip', + win64: '%s/juggler-builds/%s/%s.zip', + }, +}; + +/** + * @param {string} product + * @param {string} platform + * @param {string} revision + * @return {string} + */ +function archiveName(product, platform, revision) { + if (product === 'chromium') { + if (platform === 'linux') + return 'chrome-linux'; + if (platform === 'mac') + return 'chrome-mac'; + if (platform === 'win32' || platform === 'win64') { + // Windows archive name changed at r591479. + return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; + } + } else if (product === 'firefox') { + if (platform === 'linux') + return 'firefox-linux'; + if (platform === 'mac') + return 'firefox-mac'; + if (platform === 'win32' || platform === 'win64') + return 'firefox-' + platform; + } + return null; +} + +/** + * @param {string} product + * @param {string} platform + * @param {string} host + * @param {string} revision + * @return {string} + */ +function downloadURL(product, platform, host, revision) { + return util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision)); +} + +const readdirAsync = helper.promisify(fs.readdir.bind(fs)); +const mkdirAsync = helper.promisify(fs.mkdir.bind(fs)); +const unlinkAsync = helper.promisify(fs.unlink.bind(fs)); +const chmodAsync = helper.promisify(fs.chmod.bind(fs)); + +function existsAsync(filePath) { + let fulfill = null; + const promise = new Promise(x => fulfill = x); + fs.access(filePath, err => fulfill(!err)); + return promise; +} + +class BrowserFetcher { + /** + * @param {string} projectRoot + * @param {!BrowserFetcher.Options=} options + */ + constructor(projectRoot, options = {}) { + this._product = (options.product || 'chromium').toLowerCase(); + assert(this._product === 'chromium' || this._product === 'firefox', `Unkown product: "${options.product}"`); + this._downloadsFolder = options.path || path.join(projectRoot, '.local-browser'); + this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST; + this._platform = options.platform || ''; + if (!this._platform) { + const platform = os.platform(); + if (platform === 'darwin') + this._platform = 'mac'; + else if (platform === 'linux') + this._platform = 'linux'; + else if (platform === 'win32') + this._platform = os.arch() === 'x64' ? 'win64' : 'win32'; + assert(this._platform, 'Unsupported platform: ' + os.platform()); + } + assert(downloadURLs[this._product][this._platform], 'Unsupported platform: ' + this._platform); + } + + /** + * @return {string} + */ + platform() { + return this._platform; + } + + /** + * @param {string} revision + * @return {!Promise} + */ + canDownload(revision) { + const url = downloadURL(this._product, this._platform, this._downloadHost, revision); + let resolve; + const promise = new Promise(x => resolve = x); + const request = httpRequest(url, 'HEAD', response => { + resolve(response.statusCode === 200); + }); + request.on('error', error => { + console.error(error); + resolve(false); + }); + return promise; + } + + /** + * @param {string} revision + * @param {?function(number, number)} progressCallback + * @return {!Promise} + */ + async download(revision, progressCallback) { + const url = downloadURL(this._product, this._platform, this._downloadHost, revision); + const zipPath = path.join(this._downloadsFolder, `download-${this._product}-${this._platform}-${revision}.zip`); + const folderPath = this._getFolderPath(revision); + if (await existsAsync(folderPath)) + return this.revisionInfo(revision); + if (!(await existsAsync(this._downloadsFolder))) + await mkdirAsync(this._downloadsFolder); + try { + await downloadFile(url, zipPath, progressCallback); + await extractZip(zipPath, folderPath); + } finally { + if (await existsAsync(zipPath)) + await unlinkAsync(zipPath); + } + const revisionInfo = this.revisionInfo(revision); + if (revisionInfo) + await chmodAsync(revisionInfo.executablePath, 0o755); + return revisionInfo; + } + + /** + * @return {!Promise>} + */ + async localRevisions() { + if (!await existsAsync(this._downloadsFolder)) + return []; + const fileNames = await readdirAsync(this._downloadsFolder); + return fileNames.map(fileName => parseFolderPath(fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision); + } + + /** + * @param {string} revision + */ + async remove(revision) { + const folderPath = this._getFolderPath(revision); + assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`); + await new Promise(fulfill => removeRecursive(folderPath, fulfill)); + } + + /** + * @param {string} revision + * @return {!BrowserFetcher.RevisionInfo} + */ + revisionInfo(revision) { + const folderPath = this._getFolderPath(revision); + let executablePath = ''; + if (this._product === 'chromium') { + if (this._platform === 'mac') + executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'Chromium.app', 'Contents', 'MacOS', 'Chromium'); + else if (this._platform === 'linux') + executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'chrome'); + else if (this._platform === 'win32' || this._platform === 'win64') + executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'chrome.exe'); + else + throw new Error('Unsupported platform: ' + this._platform); + } else if (this._product === 'firefox') { + if (this._platform === 'mac') + executablePath = path.join(folderPath, 'firefox', 'Nightly.app', 'Contents', 'MacOS', 'Firefox'); + else if (this._platform === 'linux') + executablePath = path.join(folderPath, 'firefox', 'firefox'); + else if (this._platform === 'win32' || this._platform === 'win64') + executablePath = path.join(folderPath, 'firefox', 'firefox.exe'); + else + throw new Error('Unsupported platform: ' + this._platform); + } + const url = downloadURL(this._product, this._platform, this._downloadHost, revision); + const local = fs.existsSync(folderPath); + return {revision, executablePath, folderPath, local, url}; + } + + /** + * @param {string} revision + * @return {string} + */ + _getFolderPath(revision) { + return path.join(this._downloadsFolder, this._product + '-' + this._platform + '-' + revision); + } +} + +module.exports = BrowserFetcher; + +/** + * @param {string} folderPath + * @return {?{platform: string, revision: string}} + */ +function parseFolderPath(folderPath) { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 3) + return null; + const [product, platform, revision] = splits; + if (!downloadURLs[product][platform]) + return null; + return {platform, revision}; +} + +/** + * @param {string} url + * @param {string} destinationPath + * @param {?function(number, number)} progressCallback + * @return {!Promise} + */ +function downloadFile(url, destinationPath, progressCallback) { + let fulfill, reject; + let downloadedBytes = 0; + let totalBytes = 0; + + const promise = new Promise((x, y) => { fulfill = x; reject = y; }); + + const request = httpRequest(url, 'GET', response => { + if (response.statusCode !== 200) { + const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`); + // consume response data to free up memory + response.resume(); + reject(error); + return; + } + const file = fs.createWriteStream(destinationPath); + file.on('finish', () => fulfill()); + file.on('error', error => reject(error)); + response.pipe(file); + totalBytes = parseInt(/** @type {string} */ (response.headers['content-length']), 10); + if (progressCallback) + response.on('data', onData); + }); + request.on('error', error => reject(error)); + return promise; + + function onData(chunk) { + downloadedBytes += chunk.length; + progressCallback(downloadedBytes, totalBytes); + } +} + +/** + * @param {string} zipPath + * @param {string} folderPath + * @return {!Promise} + */ +function extractZip(zipPath, folderPath) { + return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => { + if (err) + reject(err); + else + fulfill(); + })); +} + +function httpRequest(url, method, response) { + /** @type {Object} */ + const options = URL.parse(url); + options.method = method; + + const proxyURL = getProxyForUrl(url); + if (proxyURL) { + /** @type {Object} */ + const parsedProxyURL = URL.parse(proxyURL); + parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:'; + + options.agent = new ProxyAgent(parsedProxyURL); + options.rejectUnauthorized = false; + } + + const requestCallback = res => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) + httpRequest(res.headers.location, method, response); + else + response(res); + }; + const request = options.protocol === 'https:' ? + require('https').request(options, requestCallback) : + require('http').request(options, requestCallback); + request.end(); + return request; +} + +/** + * @typedef {Object} BrowserFetcher.Options + * @property {string=} platform + * @property {string=} path + * @property {string=} host + */ + +/** + * @typedef {Object} BrowserFetcher.RevisionInfo + * @property {string} folderPath + * @property {string} executablePath + * @property {string} url + * @property {boolean} local + * @property {string} revision + */ diff --git a/experimental/puppeteer-firefox/lib/firefox/Connection.js b/experimental/puppeteer-firefox/lib/firefox/Connection.js new file mode 100644 index 00000000..1dbad565 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Connection.js @@ -0,0 +1,123 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const debugProtocol = require('debug')('hdfox:protocol'); +const EventEmitter = require('events'); + +/** + * @internal + */ +class Connection extends EventEmitter { + /** + * @param {!Puppeteer.ConnectionTransport} transport + * @param {number=} delay + */ + constructor(transport, delay = 0) { + super(); + this._lastId = 0; + /** @type {!Map}*/ + this._callbacks = new Map(); + this._delay = delay; + + this._transport = transport; + this._transport.onmessage = this._onMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + this._closed = false; + } + + /** + * @param {string} method + * @param {!Object=} params + * @return {!Promise} + */ + send(method, params = {}) { + const id = ++this._lastId; + const message = JSON.stringify({id, method, params}); + debugProtocol('SEND â–º ' + message); + this._transport.send(message); + return new Promise((resolve, reject) => { + this._callbacks.set(id, {resolve, reject, error: new Error(), method}); + }); + } + + /** + * @param {string} message + */ + async _onMessage(message) { + if (this._delay) + await new Promise(f => setTimeout(f, this._delay)); + debugProtocol('â—€ RECV ' + message); + const object = JSON.parse(message); + if (object.id) { + const callback = this._callbacks.get(object.id); + // Callbacks could be all rejected if someone has called `.dispose()`. + if (callback) { + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } + } else { + this.emit(object.method, object.params); + } + } + + _onClose() { + if (this._closed) + return; + this._closed = true; + this._transport.onmessage = null; + this._transport.onclose = null; + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); + this._callbacks.clear(); + this.emit(Connection.Events.Disconnected); + } + + dispose() { + this._onClose(); + this._transport.close(); + } +} + +Connection.Events = { + Disconnected: Symbol('Connection.Events.Disconnected'), +}; + +/** + * @param {!Error} error + * @param {string} method + * @param {{error: {message: string, data: any}}} object + * @return {!Error} + */ +function createProtocolError(error, method, object) { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) + message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +/** + * @param {!Error} error + * @param {string} message + * @return {!Error} + */ +function rewriteError(error, message) { + error.message = message; + return error; +} + +module.exports = {Connection}; diff --git a/experimental/puppeteer-firefox/lib/firefox/Dialog.js b/experimental/puppeteer-firefox/lib/firefox/Dialog.js new file mode 100644 index 00000000..e985b63c --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Dialog.js @@ -0,0 +1,57 @@ +const {helper, assert, debugError} = require('./helper'); + +class Dialog { + constructor(client, payload) { + this._client = client; + this._dialogId = payload.dialogId; + this._type = payload.type; + this._message = payload.message; + this._handled = false; + this._defaultValue = payload.defaultValue || ''; + } + + /** + * @return {string} + */ + type() { + return this._type; + } + + /** + * @return {string} + */ + message() { + return this._message; + } + + /** + * @return {string} + */ + defaultValue() { + return this._defaultValue; + } + + /** + * @param {string=} promptText + */ + async accept(promptText) { + assert(!this._handled, 'Cannot accept dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleDialog', { + dialogId: this._dialogId, + accept: true, + promptText: promptText + }).catch(debugError); + } + + async dismiss() { + assert(!this._handled, 'Cannot dismiss dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleDialog', { + dialogId: this._dialogId, + accept: false + }).catch(debugError); + } +} + +module.exports = {Dialog}; diff --git a/experimental/puppeteer-firefox/lib/firefox/FirefoxTransport.js b/experimental/puppeteer-firefox/lib/firefox/FirefoxTransport.js new file mode 100644 index 00000000..9997b390 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/FirefoxTransport.js @@ -0,0 +1,126 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const {Socket} = require('net'); + +/** + * @implements {!Puppeteer.ConnectionTransport} + * @internal + */ +class FirefoxTransport { + /** + * @param {number} port + * @return {!Promise} + */ + static async create(port) { + const socket = new Socket(); + try { + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + socket.connect({ + port, + host: 'localhost' + }); + }); + } catch (e) { + socket.destroy(); + throw e; + } + return new FirefoxTransport(socket); + } + + /** + * @param {!Socket} socket + */ + constructor(socket) { + this._socket = socket; + this._socket.once('close', had_error => { + if (this.onclose) + this.onclose.call(null); + }); + this._dispatchQueue = new DispatchQueue(this); + let buffer = Buffer.from(''); + socket.on('data', async data => { + buffer = Buffer.concat([buffer, data]); + while (true) { + const bufferString = buffer.toString(); + const seperatorIndex = bufferString.indexOf(':'); + if (seperatorIndex === -1) + return; + const length = parseInt(bufferString.substring(0, seperatorIndex), 10); + if (buffer.length < length + seperatorIndex) + return; + const message = buffer.slice(seperatorIndex + 1, seperatorIndex + 1 + length).toString(); + buffer = buffer.slice(seperatorIndex + 1 + length); + this._dispatchQueue.enqueue(message); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this._socket.on('error', () => {}); + this.onmessage = null; + this.onclose = null; + } + + /** + * @param {string} message + */ + send(message) { + this._socket.write(Buffer.byteLength(message) + ':' + message); + } + + close() { + this._socket.destroy(); + } +} + +// We want to dispatch all "message" events in separate tasks +// to make sure all message-related promises are resolved first +// before dispatching next message. +// +// We cannot just use setTimeout() in Node.js here like we would +// do in Browser - see https://github.com/nodejs/node/issues/23773 +// Thus implement a dispatch queue that enforces new tasks manually. +/** + * @internal + */ +class DispatchQueue { + constructor(transport) { + this._transport = transport; + + this._timeoutId = null; + this._queue = []; + this._dispatch = this._dispatch.bind(this); + } + + enqueue(message) { + this._queue.push(message); + if (!this._timeoutId) + this._timeoutId = setTimeout(this._dispatch, 0); + } + + _dispatch() { + const message = this._queue.shift(); + if (this._queue.length) + this._timeoutId = setTimeout(this._dispatch, 0) + else + this._timeoutId = null; + + if (this._transport.onmessage) + this._transport.onmessage.call(null, message); + } +} + +module.exports = FirefoxTransport; diff --git a/experimental/puppeteer-firefox/lib/firefox/Input.js b/experimental/puppeteer-firefox/lib/firefox/Input.js new file mode 100644 index 00000000..1a11efff --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Input.js @@ -0,0 +1,295 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const keyDefinitions = require('./USKeyboardLayout'); +const os = require('os'); + +/** + * @typedef {Object} KeyDescription + * @property {number} keyCode + * @property {string} key + * @property {string} text + * @property {string} code + * @property {number} location + */ + +class Keyboard { + constructor(client) { + this._client = client; + this._modifiers = 0; + this._pressedKeys = new Set(); + } + + /** + * @param {string} key + */ + async down(key) { + const description = this._keyDescriptionForString(key); + + const repeat = this._pressedKeys.has(description.code); + this._pressedKeys.add(description.code); + this._modifiers |= this._modifierBit(description.key); + + await this._client.send('Page.dispatchKeyEvent', { + type: 'keydown', + keyCode: description.keyCode, + code: description.code, + key: description.key, + repeat, + location: description.location + }); + } + + /** + * @param {string} key + * @return {number} + */ + _modifierBit(key) { + if (key === 'Alt') + return 1; + if (key === 'Control') + return 2; + if (key === 'Shift') + return 4; + if (key === 'Meta') + return 8; + return 0; + } + + /** + * @param {string} keyString + * @return {KeyDescription} + */ + _keyDescriptionForString(keyString) { + const shift = this._modifiers & 8; + const description = { + key: '', + keyCode: 0, + code: '', + text: '', + location: 0 + }; + const definition = keyDefinitions[keyString]; + if (!definition) + throw new Error(`Unknown key: "${keyString}"`); + + if (definition.key) + description.key = definition.key; + if (shift && definition.shiftKey) + description.key = definition.shiftKey; + + if (definition.keyCode) + description.keyCode = definition.keyCode; + if (shift && definition.shiftKeyCode) + description.keyCode = definition.shiftKeyCode; + + if (definition.code) + description.code = definition.code; + + if (definition.location) + description.location = definition.location; + + if (description.key.length === 1) + description.text = description.key; + + if (definition.text) + description.text = definition.text; + if (shift && definition.shiftText) + description.text = definition.shiftText; + + // if any modifiers besides shift are pressed, no text should be sent + if (this._modifiers & ~8) + description.text = ''; + + // Firefox calls the 'Meta' key 'OS' on everything but mac + if (os.platform() !== 'darwin' && description.key === 'Meta') + description.key = 'OS'; + if (description.code === 'MetaLeft') + description.code = 'OSLeft'; + if (description.code === 'MetaRight') + description.code = 'OSRight'; + return description; + } + + /** + * @param {string} key + */ + async up(key) { + const description = this._keyDescriptionForString(key); + + this._modifiers &= ~this._modifierBit(description.key); + this._pressedKeys.delete(description.code); + await this._client.send('Page.dispatchKeyEvent', { + type: 'keyup', + key: description.key, + keyCode: description.keyCode, + code: description.code, + location: description.location, + repeat: false + }); + } + + /** + * @param {string} char + */ + async sendCharacter(char) { + await this._client.send('Page.insertText', { + text: char + }); + } + + /** + * @param {string} text + * @param {!{delay?: number}=} options + */ + async type(text, options = {}) { + const {delay = null} = options; + for (const char of text) { + if (keyDefinitions[char]) + await this.press(char, {delay}); + else + await this.sendCharacter(char); + if (delay !== null) + await new Promise(f => setTimeout(f, delay)); + } + } + + /** + * @param {string} key + * @param {!{delay?: number}=} options + */ + async press(key, options = {}) { + const {delay = null} = options; + await this.down(key); + if (delay !== null) + await new Promise(f => setTimeout(f, options.delay)); + await this.up(key); + } +} + +class Mouse { + /** + * @param {!Keyboard} keyboard + */ + constructor(client, keyboard) { + this._client = client; + this._keyboard = keyboard; + this._x = 0; + this._y = 0; + this._buttons = 0; + } + + /** + * @param {number} x + * @param {number} y + * @param {{steps?: number}=} options + */ + async move(x, y, options = {}) { + const {steps = 1} = options; + const fromX = this._x, fromY = this._y; + this._x = x; + this._y = y; + for (let i = 1; i <= steps; i++) { + await this._client.send('Page.dispatchMouseEvent', { + type: 'mousemove', + button: 0, + x: fromX + (this._x - fromX) * (i / steps), + y: fromY + (this._y - fromY) * (i / steps), + modifiers: this._keyboard._modifiers, + buttons: this._buttons, + }); + } + } + + /** + * @param {number} x + * @param {number} y + * @param {!{delay?: number, button?: string, clickCount?: number}=} options + */ + async click(x, y, options = {}) { + const {delay = null} = options; + this.move(x, y); + this.down(options); + if (delay !== null) + await new Promise(f => setTimeout(f, delay)); + await this.up(options); + } + + /** + * @param {!{button?: string, clickCount?: number}=} options + */ + async down(options = {}) { + const { + button = "left", + clickCount = 1 + } = options; + if (button === 'left') + this._buttons |= 1; + if (button === 'right') + this._buttons |= 2; + if (button === 'middle') + this._buttons |= 4; + await this._client.send('Page.dispatchMouseEvent', { + type: 'mousedown', + button: this._buttonNameToButton(button), + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount, + buttons: this._buttons, + }); + } + + /** + * @param {string} buttonName + * @return {number} + */ + _buttonNameToButton(buttonName) { + if (buttonName === 'left') + return 0; + if (buttonName === 'middle') + return 1; + if (buttonName === 'right') + return 2; + } + + /** + * @param {!{button?: string, clickCount?: number}=} options + */ + async up(options = {}) { + const { + button = "left", + clickCount = 1 + } = options; + if (button === 'left') + this._buttons &= ~1; + if (button === 'right') + this._buttons &= ~2; + if (button === 'middle') + this._buttons &= ~4; + await this._client.send('Page.dispatchMouseEvent', { + type: 'mouseup', + button: this._buttonNameToButton(button), + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount: clickCount, + buttons: this._buttons, + }); + } +} + +module.exports = { Keyboard, Mouse }; diff --git a/experimental/puppeteer-firefox/lib/firefox/Launcher.js b/experimental/puppeteer-firefox/lib/firefox/Launcher.js new file mode 100644 index 00000000..2365e2f5 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Launcher.js @@ -0,0 +1,206 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const os = require('os'); +const path = require('path'); +const removeFolder = require('rimraf'); +const childProcess = require('child_process'); +const {Connection} = require('./Connection'); +const {Browser} = require('./Browser'); +const readline = require('readline'); +const fs = require('fs'); +const util = require('util'); +const {helper} = require('./helper'); +const {TimeoutError} = require('../Errors') +const FirefoxTransport = require('./FirefoxTransport'); + +const mkdtempAsync = util.promisify(fs.mkdtemp); +const removeFolderAsync = util.promisify(removeFolder); + +const FIREFOX_PROFILE_PATH = path.join(os.tmpdir(), 'puppeteer_firefox_profile-'); + +/** + * @internal + */ +class Launcher { + /** + * @param {Object} options + * @return {!Promise} + */ + async launch(options = {}) { + const { + args = [], + dumpio = false, + executablePath = null, + handleSIGHUP = true, + handleSIGINT = true, + handleSIGTERM = true, + ignoreHTTPSErrors = false, + headless = true, + defaultViewport = {width: 800, height: 600}, + slowMo = 0, + } = options; + + if (!executablePath) + throw new Error('Firefox launching is only supported with local version of firefox!'); + + const firefoxArguments = args.slice(); + firefoxArguments.push('-no-remote'); + firefoxArguments.push('-juggler', '0'); + firefoxArguments.push('-foreground'); + if (headless) + firefoxArguments.push('-headless'); + let temporaryProfileDir = null; + if (!firefoxArguments.some(arg => arg.startsWith('-profile') || arg.startsWith('--profile'))) { + temporaryProfileDir = await mkdtempAsync(FIREFOX_PROFILE_PATH); + firefoxArguments.push(`-profile`, temporaryProfileDir); + } + if (firefoxArguments.every(arg => arg.startsWith('--') || arg.startsWith('-'))) + firefoxArguments.push('about:blank'); + + const stdio = ['pipe', 'pipe', 'pipe']; + const firefoxProcess = childProcess.spawn( + executablePath, + firefoxArguments, + { + // On non-windows platforms, `detached: false` makes child process a leader of a new + // process group, making it possible to kill child process tree with `.kill(-pid)` command. + // @see https://nodejs.org/api/child_process.html#child_process_options_detached + detached: process.platform !== 'win32', + stdio + } + ); + + if (dumpio) { + firefoxProcess.stderr.pipe(process.stderr); + firefoxProcess.stdout.pipe(process.stdout); + } + + let firefoxClosed = false; + const waitForFirefoxToClose = new Promise((fulfill, reject) => { + firefoxProcess.once('exit', () => { + firefoxClosed = true; + // Cleanup as processes exit. + if (temporaryProfileDir) { + removeFolderAsync(temporaryProfileDir) + .then(() => fulfill()) + .catch(err => console.error(err)); + } else { + fulfill(); + } + }); + }); + + const listeners = [ helper.addEventListener(process, 'exit', killFirefox) ]; + if (handleSIGINT) + listeners.push(helper.addEventListener(process, 'SIGINT', () => { killFirefox(); process.exit(130); })); + if (handleSIGTERM) + listeners.push(helper.addEventListener(process, 'SIGTERM', killFirefox)); + if (handleSIGHUP) + listeners.push(helper.addEventListener(process, 'SIGHUP', killFirefox)); + /** @type {?Connection} */ + let connection = null; + try { + const port = await waitForWSEndpoint(firefoxProcess, 30000); + const transport = await FirefoxTransport.create(parseInt(port, 10)); + connection = new Connection(transport, slowMo); + const browser = new Browser(connection, defaultViewport, firefoxProcess, killFirefox); + if (ignoreHTTPSErrors) + await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true}); + if (!browser.targets().length) + await new Promise(x => browser.once('targetcreated', x)); + return browser; + } catch (e) { + killFirefox(); + throw e; + } + + // This method has to be sync to be used as 'exit' event handler. + function killFirefox() { + helper.removeEventListeners(listeners); + if (firefoxProcess.pid && !firefoxProcess.killed && !firefoxClosed) { + // Force kill chrome. + try { + if (process.platform === 'win32') + childProcess.execSync(`taskkill /pid ${firefoxProcess.pid} /T /F`); + else + process.kill(-firefoxProcess.pid, 'SIGKILL'); + } catch (e) { + // the process might have already stopped + } + } + // Attempt to remove temporary profile directory to avoid littering. + try { + removeFolder.sync(temporaryProfileDir); + } catch (e) { } + } + } +} + +/** + * @param {!Puppeteer.ChildProcess} firefoxProcess + * @param {number} timeout + * @return {!Promise} + */ +function waitForWSEndpoint(firefoxProcess, timeout) { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: firefoxProcess.stdout }); + let stderr = ''; + const listeners = [ + helper.addEventListener(rl, 'line', onLine), + helper.addEventListener(rl, 'close', () => onClose()), + helper.addEventListener(firefoxProcess, 'exit', () => onClose()), + helper.addEventListener(firefoxProcess, 'error', error => onClose(error)) + ]; + const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; + + /** + * @param {!Error=} error + */ + function onClose(error) { + cleanup(); + reject(new Error([ + 'Failed to launch Firefox!' + (error ? ' ' + error.message : ''), + stderr, + '', + ].join('\n'))); + } + + function onTimeout() { + cleanup(); + reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Firefox!`)); + } + + /** + * @param {string} line + */ + function onLine(line) { + stderr += line + '\n'; + const match = line.match(/^Juggler listening on (\d+)$/); + if (!match) + return; + cleanup(); + resolve(match[1]); + } + + function cleanup() { + if (timeoutId) + clearTimeout(timeoutId); + helper.removeEventListeners(listeners); + } + }); +} + +module.exports = {Launcher}; diff --git a/experimental/puppeteer-firefox/lib/firefox/Page.js b/experimental/puppeteer-firefox/lib/firefox/Page.js new file mode 100644 index 00000000..d779f36c --- /dev/null +++ b/experimental/puppeteer-firefox/lib/firefox/Page.js @@ -0,0 +1,1821 @@ +const {helper, assert, debugError} = require('./helper'); +const {Keyboard, Mouse} = require('./Input'); +const {constants} = require('../common'); +const {Dialog} = require('./Dialog'); +const {TimeoutError} = require('../Errors'); +const fs = require('fs'); +const mime = require('mime'); +const util = require('util'); +const EventEmitter = require('events'); + +const writeFileAsync = util.promisify(fs.writeFile); +const readFileAsync = util.promisify(fs.readFile); + +/** + * @internal + */ +class PageSession extends EventEmitter { + constructor(connection, pageId) { + super(); + this._connection = connection; + this._pageId = pageId; + const wrapperSymbol = Symbol('listenerWrapper'); + + function wrapperListener(listener, params) { + if (params.pageId === pageId) + listener.call(null, params); + } + + this.on('removeListener', (eventName, listener) => { + this._connection.removeListener(eventName, listener[wrapperSymbol]); + }); + this.on('newListener', (eventName, listener) => { + if (!listener[wrapperSymbol]) + listener[wrapperSymbol] = wrapperListener.bind(null, listener); + this._connection.on(eventName, listener[wrapperSymbol]); + }); + } + + async send(method, params = {}) { + params = Object.assign({}, params, {pageId: this._pageId}); + return await this._connection.send(method, params); + } +} + +class Page extends EventEmitter { + /** + * + * @param {!Puppeteer.Connection} connection + * @param {!Puppeteer.Target} target + * @param {string} pageId + * @param {?Puppeteer.Viewport} defaultViewport + */ + static async create(connection, target, pageId, defaultViewport) { + const session = new PageSession(connection, pageId); + const page = new Page(session, target); + await session.send('Page.enable'); + if (defaultViewport) + await page.setViewport(defaultViewport); + return page; + } + + /** + * @param {!PageSession} session + * @param {!Puppeteer.Target} target + */ + constructor(session, target) { + super(); + this._session = session; + this._target = target; + this._keyboard = new Keyboard(session); + this._mouse = new Mouse(session, this._keyboard); + this._isClosed = false; + this._mainFrame = null; + this._frames = new Map(); + this._eventListeners = [ + helper.addEventListener(this._session, 'Page.eventFired', this._onEventFired.bind(this)), + helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)), + helper.addEventListener(this._session, 'Page.consoleAPICalled', this._onConsole.bind(this)), + helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), + helper.addEventListener(this._session, 'Browser.tabClosed', this._onClosed.bind(this)), + helper.addEventListener(this._session, 'Page.frameAttached', this._onFrameAttached.bind(this)), + helper.addEventListener(this._session, 'Page.frameDetached', this._onFrameDetached.bind(this)), + helper.addEventListener(this._session, 'Page.navigationCommitted', this._onNavigationCommitted.bind(this)), + helper.addEventListener(this._session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)), + ]; + this._viewport = null; + } + + _onUncaughtError(params) { + let error = new Error(params.message); + error.stack = params.stack; + this.emit(Page.Events.PageError, error); + } + + viewport() { + return this._viewport; + } + + /** + * @param {!Puppeteer.Viewport} viewport + */ + async setViewport(viewport) { + const { + width, + height, + isMobile = false, + deviceScaleFactor = 1, + hasTouch = false, + isLandscape = false, + } = viewport; + await this._session.send('Page.setViewport', { + viewport: { width, height, isMobile, deviceScaleFactor, hasTouch, isLandscape }, + }); + const oldIsMobile = this._viewport ? this._viewport.isMobile : false; + const oldHasTouch = this._viewport ? this._viewport.hasTouch : false; + this._viewport = viewport; + if (oldIsMobile !== isMobile || oldHasTouch !== hasTouch) + await this.reload(); + } + + /** + * @param {function()|string} pageFunction + * @param {!Array<*>} args + */ + async evaluateOnNewDocument(pageFunction, ...args) { + const script = helper.evaluationString(pageFunction, ...args); + await this._session.send('Page.addScriptToEvaluateOnNewDocument', { script }); + } + + browser() { + return this._target.browser(); + } + + target() { + return this._target; + } + + url() { + return this._mainFrame.url(); + } + + frames() { + /** @type {!Array} */ + let frames = []; + collect(this._mainFrame); + return frames; + + function collect(frame) { + frames.push(frame); + for (const subframe of frame._children) + collect(subframe); + } + } + + _onDialogOpened(params) { + this.emit(Page.Events.Dialog, new Dialog(this._session, params)); + } + + _onFrameAttached(params) { + const frame = new Frame(this._session, this, params.frameId); + const parentFrame = this._frames.get(params.parentFrameId) || null; + if (parentFrame) { + frame._parentFrame = parentFrame; + parentFrame._children.add(frame); + } else { + assert(!this._mainFrame, 'INTERNAL ERROR: re-attaching main frame!'); + this._mainFrame = frame; + } + this._frames.set(params.frameId, frame); + this.emit(Page.Events.FrameAttached, frame); + } + + mainFrame() { + return this._mainFrame; + } + + _onFrameDetached(params) { + const frame = this._frames.get(params.frameId); + this._frames.delete(params.frameId); + frame._detach(); + this.emit(Page.Events.FrameDetached, frame); + } + + _onNavigationCommitted(params) { + const frame = this._frames.get(params.frameId); + frame._navigated(params.url, params.name, params.navigationId); + frame._DOMContentLoadedFired = false; + frame._loadFired = false; + this.emit(Page.Events.FrameNavigated, frame); + } + + _onSameDocumentNavigation(params) { + const frame = this._frames.get(params.frameId); + frame._url = params.url; + this.emit(Page.Events.FrameNavigated, frame); + } + + get keyboard(){ + return this._keyboard; + } + + get mouse(){ + return this._mouse; + } + + _normalizeWaitUntil(waitUntil) { + if (!Array.isArray(waitUntil)) + waitUntil = [waitUntil]; + for (const condition of waitUntil) { + if (condition !== 'load' && condition !== 'domcontentloaded') + throw new Error('Unknown waitUntil condition: ' + condition); + } + return waitUntil; + } + + /** + * @param {!{timeout?: number, waitUntil?: string|!Array}} options + */ + async waitForNavigation(options = {}) { + const { + timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, + waitUntil = ['load'], + } = options; + const frame = this._mainFrame; + const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); + + const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); + let timeoutCallback; + const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); + const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; + + const nextNavigationDog = new NextNavigationWatchdog(this._session, frame); + const error1 = await Promise.race([ + nextNavigationDog.promise(), + timeoutPromise, + ]); + nextNavigationDog.dispose(); + + // If timeout happened first - throw. + if (error1) { + clearTimeout(timeoutId); + throw error1; + } + + const {navigationId, url} = nextNavigationDog.navigation(); + + if (!navigationId) { + // Same document navigation happened. + clearTimeout(timeoutId); + return; + } + + const watchDog = new NavigationWatchdog(this._session, frame, navigationId, url, normalizedWaitUntil); + const error = await Promise.race([ + timeoutPromise, + watchDog.promise(), + ]); + watchDog.dispose(); + clearTimeout(timeoutId); + if (error) + throw error; + } + + /** + * @param {string} url + * @param {!{timeout?: number, waitUntil?: string|!Array}} options + */ + async goto(url, options = {}) { + const { + timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, + waitUntil = ['load'], + } = options; + const frame = this._mainFrame; + const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); + const {navigationId} = await this._session.send('Page.navigate', { + frameId: frame._frameId, + url, + }); + if (!navigationId) + return; + + const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); + let timeoutCallback; + const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); + const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; + + const watchDog = new NavigationWatchdog(this._session, frame, navigationId, url, normalizedWaitUntil); + const error = await Promise.race([ + timeoutPromise, + watchDog.promise(), + ]); + watchDog.dispose(); + clearTimeout(timeoutId); + if (error) + throw error; + } + + /** + * @param {!{timeout?: number, waitUntil?: string|!Array}} options + */ + async goBack(options = {}) { + const { + timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, + waitUntil = ['load'], + } = options; + const frame = this._mainFrame; + const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); + const {navigationId, navigationURL} = await this._session.send('Page.goBack', { + frameId: frame._frameId, + }); + if (!navigationId) + return; + + const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); + let timeoutCallback; + const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); + const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; + + const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); + const error = await Promise.race([ + timeoutPromise, + watchDog.promise(), + ]); + watchDog.dispose(); + clearTimeout(timeoutId); + if (error) + throw error; + } + + /** + * @param {!{timeout?: number, waitUntil?: string|!Array}} options + */ + async goForward(options = {}) { + const { + timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, + waitUntil = ['load'], + } = options; + const frame = this._mainFrame; + const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); + const {navigationId, navigationURL} = await this._session.send('Page.goForward', { + frameId: frame._frameId, + }); + if (!navigationId) + return; + + const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); + let timeoutCallback; + const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); + const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; + + const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); + const error = await Promise.race([ + timeoutPromise, + watchDog.promise(), + ]); + watchDog.dispose(); + clearTimeout(timeoutId); + if (error) + throw error; + } + + /** + * @param {!{timeout?: number, waitUntil?: string|!Array}} options + */ + async reload(options = {}) { + const { + timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, + waitUntil = ['load'], + } = options; + const frame = this._mainFrame; + const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); + const {navigationId, navigationURL} = await this._session.send('Page.reload', { + frameId: frame._frameId, + }); + if (!navigationId) + return; + + const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); + let timeoutCallback; + const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); + const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; + + const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); + const error = await Promise.race([ + timeoutPromise, + watchDog.promise(), + ]); + watchDog.dispose(); + clearTimeout(timeoutId); + if (error) + throw error; + } + + /** + * @param {{fullPage?: boolean, clip?: {width: number, height: number, x: number, y: number}, encoding?: string, path?: string}} options + * @return {Promise} + */ + async screenshot(options = {}) { + const {data} = await this._session.send('Page.screenshot', { + mimeType: getScreenshotMimeType(options), + fullPage: options.fullPage, + clip: options.clip, + }); + const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64'); + if (options.path) + await writeFileAsync(options.path, buffer); + return buffer; + } + + async evaluate(pageFunction, ...args) { + return await this._mainFrame.evaluate(pageFunction, ...args); + } + + /** + * @param {!{content?: string, path?: string, type?: string, url?: string}} options + * @return {!Promise} + */ + async addScriptTag(options) { + return await this._mainFrame.addScriptTag(options); + } + + /** + * @param {!{content?: string, path?: string, url?: string}} options + * @return {!Promise} + */ + async addStyleTag(options) { + return await this._mainFrame.addStyleTag(options); + } + + /** + * @param {string} selector + * @param {!{delay?: number, button?: string, clickCount?: number}=} options + */ + async click(selector, options = {}) { + return await this._mainFrame.click(selector, options); + } + + /** + * @param {string} selector + * @param {string} text + * @param {{delay: (number|undefined)}=} options + */ + async type(selector, text, options) { + return await this._mainFrame.type(selector, text, options); + } + + /** + * @param {string} selector + */ + async focus(selector) { + return await this._mainFrame.focus(selector); + } + + /** + * @param {string} selector + */ + async hover(selector) { + return await this._mainFrame.hover(selector); + } + + /** + * @param {(string|number|Function)} selectorOrFunctionOrTimeout + * @param {!{polling?: string|number, timeout?: number, visible?: boolean, hidden?: boolean}=} options + * @param {!Array<*>} args + * @return {!Promise} + */ + async waitFor(selectorOrFunctionOrTimeout, options = {}, ...args) { + return await this._mainFrame.waitFor(selectorOrFunctionOrTimeout, options, ...args); + } + + /** + * @param {Function|string} pageFunction + * @param {!{polling?: string|number, timeout?: number}=} options + * @return {!Promise} + */ + async waitForFunction(pageFunction, options = {}, ...args) { + return await this._mainFrame.waitForFunction(pageFunction, options, ...args); + } + + /** + * @param {string} selector + * @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options + * @return {!Promise} + */ + async waitForSelector(selector, options = {}) { + return await this._mainFrame.waitForSelector(selector, options); + } + + /** + * @param {string} xpath + * @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options + * @return {!Promise} + */ + async waitForXPath(xpath, options = {}) { + return await this._mainFrame.waitForXPath(xpath, options); + } + + /** + * @return {!Promise} + */ + async title() { + return await this._mainFrame.title(); + } + + /** + * @param {string} selector + * @return {!Promise} + */ + async $(selector) { + return await this._mainFrame.$(selector); + } + + /** + * @param {string} selector + * @return {!Promise>} + */ + async $$(selector) { + return await this._mainFrame.$$(selector); + } + + /** + * @param {string} selector + * @param {Function|String} pageFunction + * @param {!Array<*>} args + * @return {!Promise<(!Object|undefined)>} + */ + async $eval(selector, pageFunction, ...args) { + return await this._mainFrame.$eval(selector, pageFunction, ...args); + } + + /** + * @param {string} selector + * @param {Function|String} pageFunction + * @param {!Array<*>} args + * @return {!Promise<(!Object|undefined)>} + */ + async $$eval(selector, pageFunction, ...args) { + return await this._mainFrame.$$eval(selector, pageFunction, ...args); + } + + /** + * @param {string} expression + * @return {!Promise>} + */ + async $x(expression) { + return await this._mainFrame.$x(expression); + } + + async evaluateHandle(pageFunction, ...args) { + return await this._mainFrame.evaluateHandle(pageFunction, ...args); + } + + /** + * @param {string} selector + * @param {!Array} values + * @return {!Promise>} + */ + async select(selector, ...values) { + return await this._mainFrame.select(selector, ...values); + } + + async close() { + await this._session.send('Browser.closePage' ); + } + + async content() { + return await this._mainFrame.content(); + } + + /** + * @param {string} html + */ + async setContent(html) { + return await this._mainFrame.setContent(html); + } + + _onClosed() { + this._isClosed = true; + helper.removeEventListeners(this._eventListeners); + this.emit(Page.Events.Close); + } + + _onEventFired({frameId, name}) { + const frame = this._frames.get(frameId); + frame._firedEvents.add(name.toLowerCase()); + if (frame === this._mainFrame) { + if (name === 'load') + this.emit(Page.Events.Load); + else if (name === 'DOMContentLoaded') + this.emit(Page.Events.DOMContentLoaded); + } + } + + _onLoadFired({frameId}) { + const frame = this._frames.get(frameId); + frame._firedEvents.add('load'); + } + + _onConsole({type, args, frameId}) { + const frame = this._frames.get(frameId); + this.emit(Page.Events.Console, new ConsoleMessage(type, args.map(arg => createHandle(frame, arg)))); + } + + /** + * @return {boolean} + */ + isClosed() { + return this._isClosed; + } +} + +/** @enum {string} */ +Page.Events = { + Close: 'close', + Console: 'console', + Dialog: 'dialog', + DOMContentLoaded: 'domcontentloaded', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + PageError: 'pageerror' +} + +class ConsoleMessage { + /** + * @param {string} type + * @param {!Array} args + */ + constructor(type, args) { + this._type = type; + this._args = args; + } + + /** + * @return {string} + */ + type() { + return this._type; + } + + /** + * @return {!Array} + */ + args() { + return this._args; + } + + /** + * @return {string} + */ + text() { + return this._args.map(arg => { + if (arg._objectId) + return arg.toString(); + return arg._deserializeValue(arg._protocolValue); + }).join(' '); + } +} + +class JSHandle { + + /** + * @param {!Frame} frame + * @param {*} payload + */ + constructor(frame, payload) { + this._frame = frame; + this._session = this._frame._session; + this._frameId = this._frame._frameId; + this._objectId = payload.objectId; + this._type = payload.type; + this._subtype = payload.subtype; + this._protocolValue = { + unserializableValue: payload.unserializableValue, + value: payload.value, + objectId: payload.objectId, + }; + } + + /** + * @override + * @return {string} + */ + toString() { + if (this._objectId) + return 'JSHandle@' + (this._subtype || this._type); + return 'JSHandle:' + this._deserializeValue(this._protocolValue); + } + + /** + * @param {string} propertyName + * @return {!Promise} + */ + async getProperty(propertyName) { + const objectHandle = await this._frame.evaluateHandle((object, propertyName) => { + const result = {__proto__: null}; + result[propertyName] = object[propertyName]; + return result; + }, this, propertyName); + const properties = await objectHandle.getProperties(); + const result = properties.get(propertyName) || null; + await objectHandle.dispose(); + return result; + } + + /** + * @return {!Promise>} + */ + async getProperties() { + const response = await this._session.send('Page.getObjectProperties', { + frameId: this._frameId, + objectId: this._objectId, + }); + const result = new Map(); + for (const property of response.properties) { + result.set(property.name, createHandle(this._frame, property.value, null)); + } + return result; + } + + _deserializeValue({unserializableValue, value}) { + if (unserializableValue === 'Infinity') + return Infinity; + if (unserializableValue === '-Infinity') + return -Infinity; + if (unserializableValue === '-0') + return -0; + if (unserializableValue === 'NaN') + return NaN; + return value; + } + + async jsonValue() { + if (!this._objectId) + return this._deserializeValue(this._protocolValue); + const simpleValue = await this._session.send('Page.evaluate', { + frameId: this._frameId, + returnByValue: true, + functionText: (e => e).toString(), + args: [this._protocolValue], + }); + return this._deserializeValue(simpleValue.result); + } + + /** + * @return {?ElementHandle} + */ + asElement() { + return null; + } + + async dispose() { + if (!this._objectId) + return; + await this._session.send('Page.disposeObject', { + frameId: this._frameId, + objectId: this._objectId, + }); + } +} + +function getScreenshotMimeType(options) { + // options.type takes precedence over inferring the type from options.path + // because it may be a 0-length file with no extension created beforehand (i.e. as a temp file). + if (options.type) { + if (options.type === 'png') + return 'image/png'; + if (options.type === 'jpeg') + return 'image/jpeg'; + throw new Error('Unknown options.type value: ' + options.type); + } + if (options.path) { + const fileType = mime.getType(options.path); + if (fileType === 'image/png' || fileType === 'image/jpeg') + return fileType; + throw new Error('Unsupported screnshot mime type: ' + fileType); + } + return 'image/png'; +} + +class ElementHandle extends JSHandle { + + /** + * @override + * @return {!ElementHandle} + */ + asElement() { + return this; + } + + /** + * @return {!Promise<{width: number, height: number, x: number, y: number}>} + */ + async boundingBox() { + return await this._session.send('Page.getBoundingBox', { + frameId: this._frameId, + objectId: this._objectId, + }); + } + + /** + * @param {{encoding?: string, path?: string}} options + */ + async screenshot(options = {}) { + const clip = await this._session.send('Page.getBoundingBox', { + frameId: this._frameId, + objectId: this._objectId, + }); + if (!clip) + throw new Error('Node is either not visible or not an HTMLElement'); + await this._scrollIntoViewIfNeeded(); + + return await this._frame._page.screenshot(Object.assign({}, options, { + clip: { + x: Math.round(clip.x), + y: Math.round(clip.y), + width: Math.round(clip.width), + height: Math.round(clip.height), + }, + })); + } + + /** + * @returns {!Promise} + */ + isIntersectingViewport() { + return this._frame.evaluate(async element => { + const visibleRatio = await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + requestAnimationFrame(() => {}); + }); + return visibleRatio > 0; + }, this); + } + + /** + * @param {string} selector + * @return {!Promise} + */ + async $(selector) { + const handle = await this._frame.evaluateHandle( + (element, selector) => element.querySelector(selector), + this, selector + ); + const element = handle.asElement(); + if (element) + return element; + await handle.dispose(); + return null; + } + + /** + * @param {string} selector + * @return {!Promise>} + */ + async $$(selector) { + const arrayHandle = await this._frame.evaluateHandle( + (element, selector) => element.querySelectorAll(selector), + this, selector + ); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) + result.push(elementHandle); + } + return result; + } + + /** + * @param {string} selector + * @param {Function|String} pageFunction + * @param {!Array<*>} args + * @return {!Promise<(!Object|undefined)>} + */ + async $eval(selector, pageFunction, ...args) { + const elementHandle = await this.$(selector); + if (!elementHandle) + throw new Error(`Error: failed to find element matching selector "${selector}"`); + const result = await this._frame.evaluate(pageFunction, elementHandle, ...args); + await elementHandle.dispose(); + return result; + } + + /** + * @param {string} selector + * @param {Function|String} pageFunction + * @param {!Array<*>} args + * @return {!Promise<(!Object|undefined)>} + */ + async $$eval(selector, pageFunction, ...args) { + const arrayHandle = await this._frame.evaluateHandle( + (element, selector) => Array.from(element.querySelectorAll(selector)), + this, selector + ); + + const result = await this._frame.evaluate(pageFunction, arrayHandle, ...args); + await arrayHandle.dispose(); + return result; + } + + /** + * @param {string} expression + * @return {!Promise>} + */ + async $x(expression) { + const arrayHandle = await this._frame.evaluateHandle( + (element, expression) => { + const document = element.ownerDocument || element; + const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); + const array = []; + let item; + while ((item = iterator.iterateNext())) + array.push(item); + return array; + }, + this, expression + ); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) + result.push(elementHandle); + } + return result; + } + + async _scrollIntoViewIfNeeded() { + const error = await this._frame.evaluate(async(element) => { + if (!element.isConnected) + return 'Node is detached from document'; + if (element.nodeType !== Node.ELEMENT_NODE) + return 'Node is not of type HTMLElement'; + const visibleRatio = await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + requestAnimationFrame(() => {}); + }); + if (visibleRatio !== 1.0) + element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + return false; + }, this); + if (error) + throw new Error(error); + } + + /** + * @param {!{delay?: number, button?: string, clickCount?: number}=} options + */ + async click(options) { + await this._scrollIntoViewIfNeeded(); + const {x, y} = await this._clickablePoint(); + await this._frame._page.mouse.click(x, y, options); + } + + async hover() { + await this._scrollIntoViewIfNeeded(); + const {x, y} = await this._clickablePoint(); + await this._frame._page.mouse.move(x, y); + } + + async focus() { + await this._frame.evaluate(element => element.focus(), this); + } + + /** + * @param {string} text + * @param {{delay: (number|undefined)}=} options + */ + async type(text, options) { + await this.focus(); + await this._frame._page.keyboard.type(text, options); + } + + /** + * @param {string} key + * @param {!{delay?: number}=} options + */ + async press(key, options) { + await this.focus(); + await this._frame._page.keyboard.press(key, options); + } + + + /** + * @return {!Promise} + */ + async _clickablePoint() { + const result = await this._session.send('Page.getContentQuads', { + frameId: this._frameId, + objectId: this._objectId, + }).catch(debugError); + if (!result || !result.quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Filter out quads that have too small area to click into. + const quads = result.quads.filter(quad => computeQuadArea(quad) > 1); + if (!quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Return the middle point of the first quad. + return computeQuadCenter(quads[0]); + } +} + +function createHandle(frame, result, exceptionDetails) { + if (exceptionDetails) { + if (exceptionDetails.value) + throw new Error('Evaluation failed: ' + JSON.stringify(exceptionDetails.value)); + else + throw new Error('Evaluation failed: ' + exceptionDetails.text + '\n' + exceptionDetails.stack); + } + return result.subtype === 'node' ? new ElementHandle(frame, result) : new JSHandle(frame, result); +} + +class Frame { + /** + * @param {*} session + * @param {!Page} page + * @param {string} frameId + */ + constructor(session, page, frameId) { + this._session = session; + this._page = page; + this._frameId = frameId; + /** @type {?Frame} */ + this._parentFrame = null; + this._url = ''; + this._name = ''; + /** @type {!Set} */ + this._children = new Set(); + this._isDetached = false; + + this._firedEvents = new Set(); + + /** @type {!Set} */ + this._waitTasks = new Set(); + this._documentPromise = null; + } + + /** + * @param {string} selector + * @param {!{delay?: number, button?: string, clickCount?: number}=} options + */ + async click(selector, options = {}) { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.click(options); + await handle.dispose(); + } + + /** + * @param {string} selector + * @param {string} text + * @param {{delay: (number|undefined)}=} options + */ + async type(selector, text, options) { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.type(text, options); + await handle.dispose(); + } + + /** + * @param {string} selector + */ + async focus(selector) { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.focus(); + await handle.dispose(); + } + + /** + * @param {string} selector + */ + async hover(selector) { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.hover(); + await handle.dispose(); + } + + _detach() { + this._parentFrame._children.delete(this); + this._parentFrame = null; + this._isDetached = true; + for (const waitTask of this._waitTasks) + waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); + } + + _navigated(url, name, navigationId) { + this._url = url; + this._name = name; + this._lastCommittedNavigationId = navigationId; + this._documentPromise = null; + this._firedEvents.clear(); + } + + /** + * @param {string} selector + * @param {!Array} values + * @return {!Promise>} + */ + select(selector, ...values) { + for (const value of values) + assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"'); + return this.$eval(selector, (element, values) => { + if (element.nodeName.toLowerCase() !== 'select') + throw new Error('Element is not a + + + diff --git a/experimental/puppeteer-firefox/test/assets/input/keyboard.html b/experimental/puppeteer-firefox/test/assets/input/keyboard.html new file mode 100644 index 00000000..9f1d4815 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/input/keyboard.html @@ -0,0 +1,44 @@ + + + + Keyboard test + + + + + + \ No newline at end of file diff --git a/experimental/puppeteer-firefox/test/assets/input/scrollable.html b/experimental/puppeteer-firefox/test/assets/input/scrollable.html new file mode 100644 index 00000000..885d3739 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/input/scrollable.html @@ -0,0 +1,23 @@ + + + + Scrollable test + + + + + + \ No newline at end of file diff --git a/experimental/puppeteer-firefox/test/assets/input/select.html b/experimental/puppeteer-firefox/test/assets/input/select.html new file mode 100644 index 00000000..879a537a --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/input/select.html @@ -0,0 +1,69 @@ + + + + Selection Test + + + + + + diff --git a/experimental/puppeteer-firefox/test/assets/input/textarea.html b/experimental/puppeteer-firefox/test/assets/input/textarea.html new file mode 100644 index 00000000..6d5be760 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/input/textarea.html @@ -0,0 +1,15 @@ + + + + Textarea test + + + + + + + \ No newline at end of file diff --git a/experimental/puppeteer-firefox/test/assets/mobile.html b/experimental/puppeteer-firefox/test/assets/mobile.html new file mode 100644 index 00000000..8e94b2fe --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/mobile.html @@ -0,0 +1 @@ + diff --git a/experimental/puppeteer-firefox/test/assets/modernizr.js b/experimental/puppeteer-firefox/test/assets/modernizr.js new file mode 100644 index 00000000..7991a4ec --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/modernizr.js @@ -0,0 +1,3 @@ +/*! modernizr 3.5.0 (Custom Build) | MIT * +* https://modernizr.com/download/?-touchevents-setclasses !*/ +!function(e,n,t){function o(e,n){return typeof e===n}function s(){var e,n,t,s,a,i,r;for(var l in c)if(c.hasOwnProperty(l)){if(e=[],n=c[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;t + button { + position: absolute; + width: 100px; + height: 20px; + } + + #btn0 { right: 0px; top: 0; } + #btn1 { right: -10px; top: 25px; } + #btn2 { right: -20px; top: 50px; } + #btn3 { right: -30px; top: 75px; } + #btn4 { right: -40px; top: 100px; } + #btn5 { right: -50px; top: 125px; } + #btn6 { right: -60px; top: 150px; } + #btn7 { right: -70px; top: 175px; } + #btn8 { right: -80px; top: 200px; } + #btn9 { right: -90px; top: 225px; } + #btn10 { right: -100px; top: 250px; } + + + + + + + + + + + + + diff --git a/experimental/puppeteer-firefox/test/assets/one-style.css b/experimental/puppeteer-firefox/test/assets/one-style.css new file mode 100644 index 00000000..7b26410d --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/one-style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/experimental/puppeteer-firefox/test/assets/one-style.html b/experimental/puppeteer-firefox/test/assets/one-style.html new file mode 100644 index 00000000..4760f2b9 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/one-style.html @@ -0,0 +1,2 @@ + +
hello, world!
diff --git a/experimental/puppeteer-firefox/test/assets/tamperable.html b/experimental/puppeteer-firefox/test/assets/tamperable.html new file mode 100644 index 00000000..d027e970 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/tamperable.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/experimental/puppeteer-firefox/test/assets/title.html b/experimental/puppeteer-firefox/test/assets/title.html new file mode 100644 index 00000000..88a86ce4 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/title.html @@ -0,0 +1 @@ +Woof-Woof diff --git a/experimental/puppeteer-firefox/test/assets/wrappedlink.html b/experimental/puppeteer-firefox/test/assets/wrappedlink.html new file mode 100644 index 00000000..429b6e91 --- /dev/null +++ b/experimental/puppeteer-firefox/test/assets/wrappedlink.html @@ -0,0 +1,32 @@ + +
+ 123321 +
+ diff --git a/experimental/puppeteer-firefox/test/browser.spec.js b/experimental/puppeteer-firefox/test/browser.spec.js new file mode 100644 index 00000000..d2eb98de --- /dev/null +++ b/experimental/puppeteer-firefox/test/browser.spec.js @@ -0,0 +1,30 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + describe('Browser.process', function() { + it('should return child_process instance', async function({browser}) { + const process = await browser.process(); + expect(process.pid).toBeGreaterThan(0); + }); + }); +}; + + diff --git a/experimental/puppeteer-firefox/test/chromiumonly.spec.js b/experimental/puppeteer-firefox/test/chromiumonly.spec.js new file mode 100644 index 00000000..00b21c71 --- /dev/null +++ b/experimental/puppeteer-firefox/test/chromiumonly.spec.js @@ -0,0 +1,24 @@ +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + describe('Chromium-specific tests', function() { + describe('Browser.version', function() { + xit('should return whether we are in headless', async({browser}) => { + const version = await browser.version(); + expect(version.length).toBeGreaterThan(0); + expect(version.startsWith('Headless')).toBe(headless); + }); + }); + + describe('Browser.userAgent', function() { + it('should include WebKit', async({browser}) => { + const userAgent = await browser.userAgent(); + expect(userAgent.length).toBeGreaterThan(0); + expect(userAgent).toContain('WebKit'); + }); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/click.spec.js b/experimental/puppeteer-firefox/test/click.spec.js new file mode 100644 index 00000000..1384660a --- /dev/null +++ b/experimental/puppeteer-firefox/test/click.spec.js @@ -0,0 +1,211 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); +const os = require('os'); +const DeviceDescriptors = require('puppeteer/DeviceDescriptors'); +const iPhone = DeviceDescriptors['iPhone 6']; + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.click', () => { + it('should click the button', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + it('should click offscreen buttons', async({page, server}) => { + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + const messages = []; + page.on('console', msg => messages.push(msg.text())); + for (let i = 0; i < 11; ++i) { + // We might've scrolled to click a button - reset to (0, 0). + await page.evaluate(() => window.scrollTo(0, 0)); + await page.click(`#btn${i}`); + } + expect(messages).toEqual([ + 'button #0 clicked', + 'button #1 clicked', + 'button #2 clicked', + 'button #3 clicked', + 'button #4 clicked', + 'button #5 clicked', + 'button #6 clicked', + 'button #7 clicked', + 'button #8 clicked', + 'button #9 clicked', + 'button #10 clicked' + ]); + }); + it('should click wrapped links', async({page, server}) => { + await page.goto(server.PREFIX + '/wrappedlink.html'); + await page.click('a'); + expect(await page.evaluate(() => window.__clicked)).toBe(true); + }); + it('should click on checkbox input and toggle', async({page, server}) => { + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect(await page.evaluate(() => result.check)).toBe(null); + await page.click('input#agree'); + expect(await page.evaluate(() => result.check)).toBe(true); + expect(await page.evaluate(() => result.events)).toEqual([ + 'mouseover', + 'mouseenter', + 'mousemove', + 'mousedown', + 'mouseup', + 'click', + 'input', + 'change', + ]); + await page.click('input#agree'); + expect(await page.evaluate(() => result.check)).toBe(false); + }); + it('should click on checkbox label and toggle', async({page, server}) => { + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect(await page.evaluate(() => result.check)).toBe(null); + await page.click('label[for="agree"]'); + expect(await page.evaluate(() => result.check)).toBe(true); + expect(await page.evaluate(() => result.events)).toEqual([ + 'click', + 'input', + 'change', + ]); + await page.click('label[for="agree"]'); + expect(await page.evaluate(() => result.check)).toBe(false); + }); + it('should fail to click a missing button', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + let error = null; + await page.click('button.does-not-exist').catch(e => error = e); + expect(error.message).toBe('No node found for selector: button.does-not-exist'); + }); + // @see https://github.com/GoogleChrome/puppeteer/issues/161 + it('should not hang with touch-enabled viewports', async({page, server}) => { + await page.setViewport(iPhone.viewport); + await page.mouse.down(); + await page.mouse.move(100, 10); + await page.mouse.up(); + }); + it('should click the button after navigation ', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + it('should scroll and click the button', async({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-5'); + expect(await page.evaluate(() => document.querySelector('#button-5').textContent)).toBe('clicked'); + await page.click('#button-80'); + expect(await page.evaluate(() => document.querySelector('#button-80').textContent)).toBe('clicked'); + }); + it('should double click the button', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + window.double = false; + const button = document.querySelector('button'); + button.addEventListener('dblclick', event => { + window.double = true; + }); + }); + const button = await page.$('button'); + await button.click({ clickCount: 2 }); + expect(await page.evaluate('double')).toBe(true); + expect(await page.evaluate('result')).toBe('Clicked'); + }); + it('should click a partially obscured button', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + const button = document.querySelector('button'); + button.textContent = 'Some really long text that will go offscreen'; + button.style.position = 'absolute'; + button.style.left = '368px'; + }); + await page.click('button'); + expect(await page.evaluate(() => window.result)).toBe('Clicked'); + }); + it('should select the text by triple clicking', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = 'This is the text that we are going to try to select. Let\'s see how it goes.'; + await page.keyboard.type(text); + await page.click('textarea'); + await page.click('textarea', {clickCount: 2}); + await page.click('textarea', {clickCount: 3}); + expect(await page.evaluate(() => { + const textarea = document.querySelector('textarea'); + return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd); + })).toBe(text); + }); + it('should fire contextmenu event on right click', async({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', {button: 'right'}); + expect(await page.evaluate(() => document.querySelector('#button-8').textContent)).toBe('context menu'); + }); + it('should set modifier keys on click', async({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => document.querySelector('#button-3').addEventListener('mousedown', e => window.lastEvent = e, true)); + const modifiers = {'Shift': 'shiftKey', 'Control': 'ctrlKey', 'Alt': 'altKey', 'Meta': 'metaKey'}; + // In Firefox, the Meta modifier only exists on Mac + if (FFOX && os.platform() !== 'darwin') + delete modifiers['Meta']; + for (const modifier in modifiers) { + await page.keyboard.down(modifier); + await page.click('#button-3'); + if (!(await page.evaluate(mod => window.lastEvent[mod], modifiers[modifier]))) + throw new Error(modifiers[modifier] + ' should be true'); + await page.keyboard.up(modifier); + } + await page.click('#button-3'); + for (const modifier in modifiers) { + if ((await page.evaluate(mod => window.lastEvent[mod], modifiers[modifier]))) + throw new Error(modifiers[modifier] + ' should be false'); + } + }); + // @see https://github.com/GoogleChrome/puppeteer/issues/206 + it('should click links which cause navigation', async({page, server}) => { + await page.setContent(`empty.html`); + // This await should not hang. + await page.click('a'); + }); + it('should click the button inside an iframe', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent('
spacer
'); + await utils.attachFrame(page, 'button-test', server.PREFIX + '/input/button.html'); + const frame = page.frames()[1]; + const button = await frame.$('button'); + await button.click(); + expect(await frame.evaluate(() => window.result)).toBe('Clicked'); + }); + it('should click the button with deviceScaleFactor set', async({page, server}) => { + await page.setViewport({width: 400, height: 400, deviceScaleFactor: 5}); + expect(await page.evaluate(() => window.devicePixelRatio)).toBe(5); + await page.setContent('
spacer
'); + await utils.attachFrame(page, 'button-test', server.PREFIX + '/input/button.html'); + const frame = page.frames()[1]; + const button = await frame.$('button'); + await button.click(); + expect(await frame.evaluate(() => window.result)).toBe('Clicked'); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/dialog.spec.js b/experimental/puppeteer-firefox/test/dialog.spec.js new file mode 100644 index 00000000..ac4211b1 --- /dev/null +++ b/experimental/puppeteer-firefox/test/dialog.spec.js @@ -0,0 +1,39 @@ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.Events.Dialog', function() { + it('should fire', async({page, server}) => { + page.on('dialog', dialog => { + expect(dialog.type()).toBe('alert'); + expect(dialog.defaultValue()).toBe(''); + expect(dialog.message()).toBe('yo'); + dialog.accept(); + }); + await page.evaluate(() => alert('yo')); + }); + it('should allow accepting prompts', async({page, server}) => { + page.on('dialog', dialog => { + expect(dialog.type()).toBe('prompt'); + expect(dialog.defaultValue()).toBe('yes.'); + expect(dialog.message()).toBe('question?'); + dialog.accept('answer!'); + }); + const result = await page.evaluate(() => prompt('question?', 'yes.')); + expect(result).toBe('answer!'); + }); + it('should dismiss the prompt', async({page, server}) => { + page.on('dialog', dialog => { + dialog.dismiss(); + }); + const result = await page.evaluate(() => prompt('question?')); + expect(result).toBe(null); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/elementhandle.spec.js b/experimental/puppeteer-firefox/test/elementhandle.spec.js new file mode 100644 index 00000000..7d8909f4 --- /dev/null +++ b/experimental/puppeteer-firefox/test/elementhandle.spec.js @@ -0,0 +1,103 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('JSHandle.asElement', function() { + it('should work', async({page, server}) => { + await page.setContent('
test
'); + const handle = await page.evaluateHandle(() => document.querySelector('section')); + const element = handle.asElement(); + expect(element).not.toBe(null); + }); + it('should work with nullified Node', async({page, server}) => { + await page.setContent('
test
'); + await page.evaluate(() => delete Node); + const handle = await page.evaluateHandle(() => document.querySelector('section')); + const element = handle.asElement(); + expect(element).not.toBe(null); + }); + it('should return null for non-elements', async({page, server}) => { + const handle = await page.evaluateHandle(() => ({foo: 'bar'})); + expect(handle.asElement()).toBe(null); + }); + }); + + describe('ElementHandle.isIntersectingViewport', function() { + it('should work', async({page, server}) => { + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + for (let i = 0; i < 11; ++i) { + const button = await page.$('#btn' + i); + // All but last button are visible. + const visible = i < 10; + expect(await button.isIntersectingViewport()).toBe(visible); + } + }); + }); + + describe('ElementHandle.boundingBox', function() { + it('should work', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const elementHandle = await page.$('.box:nth-of-type(13)'); + const box = await elementHandle.boundingBox(); + expect(box).toEqual({ x: 100, y: 50, width: 50, height: 50 }); + }); + xit('should handle nested frames', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + const nestedFrame = page.frames()[1].childFrames()[1]; + const elementHandle = await nestedFrame.$('div'); + const box = await elementHandle.boundingBox(); + await new Promise(() => {}); + expect(box).toEqual({ x: 28, y: 260, width: 264, height: 18 }); + }); + it('should return null for invisible elements', async({page, server}) => { + await page.setContent('
hi
'); + const element = await page.$('div'); + expect(await element.boundingBox()).toBe(null); + }); + it('should force a layout', async({page, server}) => { + await page.setViewport({ width: 500, height: 500 }); + await page.setContent('
hello
'); + const elementHandle = await page.$('div'); + await page.evaluate(element => element.style.height = '200px', elementHandle); + const box = await elementHandle.boundingBox(); + expect(box).toEqual({ x: 8, y: 8, width: 100, height: 200 }); + }); + it('should work with SVG nodes', async({page, server}) => { + await page.setContent(` + + + + `); + const element = await page.$('#therect'); + const pptrBoundingBox = await element.boundingBox(); + const webBoundingBox = await page.evaluate(e => { + const rect = e.getBoundingClientRect(); + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }, element); + expect(pptrBoundingBox).toEqual(webBoundingBox); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/emulation.spec.js b/experimental/puppeteer-firefox/test/emulation.spec.js new file mode 100644 index 00000000..61acb7cf --- /dev/null +++ b/experimental/puppeteer-firefox/test/emulation.spec.js @@ -0,0 +1,86 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const DeviceDescriptors = require('puppeteer/DeviceDescriptors'); +const iPhone = DeviceDescriptors['iPhone 6']; +const iPhoneLandscape = DeviceDescriptors['iPhone 6 landscape']; + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.viewport', function() { + it('should get the proper viewport size', async({page, server}) => { + expect(page.viewport()).toEqual({width: 800, height: 600}); + await page.setViewport({width: 123, height: 456}); + expect(page.viewport()).toEqual({width: 123, height: 456}); + }); + it('should support mobile emulation', async({page, server}) => { + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => window.innerWidth)).toBe(800); + await page.setViewport(iPhone.viewport); + expect(await page.evaluate(() => window.innerWidth)).toBe(375); + await page.setViewport({width: 400, height: 300}); + expect(await page.evaluate(() => window.innerWidth)).toBe(400); + }); + it('should support touch emulation', async({page, server}) => { + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(false); + await page.setViewport(iPhone.viewport); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(true); + expect(await page.evaluate(dispatchTouch)).toBe('Received touch'); + await page.setViewport({width: 100, height: 100}); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(false); + + function dispatchTouch() { + let fulfill; + const promise = new Promise(x => fulfill = x); + window.ontouchstart = function(e) { + fulfill('Received touch'); + }; + window.dispatchEvent(new Event('touchstart')); + + fulfill('Did not receive touch'); + + return promise; + } + }); + it('should be detectable by Modernizr', async({page, server}) => { + await page.goto(server.PREFIX + '/detect-touch.html'); + expect(await page.evaluate(() => document.body.textContent.trim())).toBe('NO'); + await page.setViewport(iPhone.viewport); + await page.goto(server.PREFIX + '/detect-touch.html'); + expect(await page.evaluate(() => document.body.textContent.trim())).toBe('YES'); + }); + it('should detect touch when applying viewport with touches', async({page, server}) => { + await page.setViewport({ width: 800, height: 600, hasTouch: true }); + await page.addScriptTag({url: server.PREFIX + '/modernizr.js'}); + expect(await page.evaluate(() => Modernizr.touchevents)).toBe(true); + }); + xit('should support landscape emulation', async({page, server}) => { + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => screen.orientation.type)).toBe('portrait-primary'); + await page.setViewport(iPhoneLandscape.viewport); + expect(await page.evaluate(() => screen.orientation.type)).toBe('landscape-primary'); + await page.setViewport({width: 100, height: 100}); + expect(await page.evaluate(() => screen.orientation.type)).toBe('portrait-primary'); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/evaluation.spec.js b/experimental/puppeteer-firefox/test/evaluation.spec.js new file mode 100644 index 00000000..4ba61742 --- /dev/null +++ b/experimental/puppeteer-firefox/test/evaluation.spec.js @@ -0,0 +1,181 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.evaluate', function() { + it('should work', async({page, server}) => { + const result = await page.evaluate(() => 7 * 3); + expect(result).toBe(21); + }); + it('should throw when evaluation triggers reload', async({page, server}) => { + let error = null; + await page.evaluate(() => { + location.reload(); + return new Promise(resolve => { + setTimeout(() => resolve(1), 0); + }); + }).catch(e => error = e); + expect(error.message).toContain('Protocol error'); + }); + it('should await promise', async({page, server}) => { + const result = await page.evaluate(() => Promise.resolve(8 * 7)); + expect(result).toBe(56); + }); + it('should reject promise with exception', async({page, server}) => { + let error = null; + await page.evaluate(() => not.existing.object.property).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('not is not defined'); + }); + it('should support thrown strings as error messages', async({page, server}) => { + let error = null; + await page.evaluate(() => { throw 'qwerty'; }).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('qwerty'); + }); + it('should support thrown numbers as error messages', async({page, server}) => { + let error = null; + await page.evaluate(() => { throw 100500; }).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('100500'); + }); + it('should return complex objects', async({page, server}) => { + const object = {foo: 'bar!'}; + const result = await page.evaluate(a => a, object); + expect(result).not.toBe(object); + expect(result).toEqual(object); + }); + it('should transfer NaN', async({page, server}) => { + const result = await page.evaluate(a => a, NaN); + expect(Object.is(result, NaN)).toBe(true); + }); + it('should transfer -0', async({page, server}) => { + const result = await page.evaluate(a => a, -0); + expect(Object.is(result, -0)).toBe(true); + }); + it('should transfer Infinity', async({page, server}) => { + const result = await page.evaluate(a => a, Infinity); + expect(Object.is(result, Infinity)).toBe(true); + }); + it('should transfer -Infinity', async({page, server}) => { + const result = await page.evaluate(a => a, -Infinity); + expect(Object.is(result, -Infinity)).toBe(true); + }); + it('should transfer arrays', async({page, server}) => { + const result = await page.evaluate(a => a, [1, 2, 3]); + expect(result).toEqual([1,2,3]); + }); + it('should transfer arrays as arrays, not objects', async({page, server}) => { + const result = await page.evaluate(a => Array.isArray(a), [1, 2, 3]); + expect(result).toBe(true); + }); + it('should accept "undefined" as one of multiple parameters', async({page, server}) => { + const result = await page.evaluate((a, b) => Object.is(a, undefined) && Object.is(b, 'foo'), undefined, 'foo'); + expect(result).toBe(true); + }); + it('should properly serialize null fields', async({page}) => { + expect(await page.evaluate(() => ({a: undefined}))).toEqual({}); + }); + it('should return undefined for non-serializable objects', async({page, server}) => { + expect(await page.evaluate(() => window)).toBe(undefined); + }); + xit('should return undefined for objects with symbols', async({page, server}) => { + expect(await page.evaluate(() => [Symbol('foo4')])).toBe(undefined); + }); + it('should fail for circular object', async({page, server}) => { + const result = await page.evaluate(() => { + const a = {}; + const b = {a}; + a.b = b; + return a; + }); + expect(result).toBe(undefined); + }); + it('should accept a string', async({page, server}) => { + const result = await page.evaluate('1 + 2'); + expect(result).toBe(3); + }); + it('should accept a string with semi colons', async({page, server}) => { + const result = await page.evaluate('1 + 5;'); + expect(result).toBe(6); + }); + it('should accept a string with comments', async({page, server}) => { + const result = await page.evaluate('2 + 5;\n// do some math!'); + expect(result).toBe(7); + }); + it('should simulate a user gesture', async({page, server}) => { + const result = await page.evaluate(() => document.execCommand('copy')); + expect(result).toBe(true); + }); + it('should evaluate in the page context', async({page, server}) => { + await page.goto(server.PREFIX + '/global-var.html'); + expect(await page.evaluate('globalVar')).toBe(123); + }); + it('should use the same sandbox', async({page}) => { + await page.evaluate(() => window.globalVar = 123); + expect(await page.evaluate('globalVar')).toBe(123); + }); + }); + + describe('Frame.evaluate', function() { + it('should have different execution contexts', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE2); + expect(page.frames().length).toBe(2); + await page.frames()[0].evaluate(() => window.FOO = 'foo'); + await page.frames()[1].evaluate(() => window.FOO = 'bar'); + expect(await page.frames()[0].evaluate(() => window.FOO)).toBe('foo'); + expect(await page.frames()[1].evaluate(() => window.FOO)).toBe('bar'); + }); + it('should have correct execution contexts', async({page, server}) => { + await page.goto(server.PREFIX + '/frames/one-frame.html'); + expect(page.frames().length).toBe(2); + expect(await page.frames()[0].evaluate(() => document.body.textContent.trim())).toBe(''); + expect(await page.frames()[1].evaluate(() => document.body.textContent.trim())).toBe(`Hi, I'm frame`); + }); + }); + + describe('Page.evaluateOnNewDocument', function() { + it('should evaluate before anything else on the page', async({page, server}) => { + await page.evaluateOnNewDocument(function(){ + window.injected = 123; + }); + await page.goto(server.PREFIX + '/tamperable.html'); + expect(await page.evaluate(() => window.result)).toBe(123); + }); + it('should work with CSP', async({page, server}) => { + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.evaluateOnNewDocument(function(){ + window.injected = 123; + }); + await page.goto(server.PREFIX + '/empty.html'); + expect(await page.evaluate(() => window.injected)).toBe(123); + + // Make sure CSP works. + await page.addScriptTag({content: 'window.e = 10;'}).catch(e => void e); + expect(await page.evaluate(() => window.e)).toBe(undefined); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/firefoxonly.spec.js b/experimental/puppeteer-firefox/test/firefoxonly.spec.js new file mode 100644 index 00000000..5babfda2 --- /dev/null +++ b/experimental/puppeteer-firefox/test/firefoxonly.spec.js @@ -0,0 +1,24 @@ +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + describe('Firefox-specific tests', function() { + describe('Browser.version', function() { + it('should return whether we are in headless', async({browser}) => { + const version = await browser.version(); + expect(version.length).toBeGreaterThan(0); + expect(version.startsWith('Firefox/')).toBe(true); + }); + }); + + describe('Browser.userAgent', function() { + it('should include WebKit', async({browser}) => { + const userAgent = await browser.userAgent(); + expect(userAgent.length).toBeGreaterThan(0); + expect(userAgent).toContain('Gecko'); + }); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/frame.spec.js b/experimental/puppeteer-firefox/test/frame.spec.js new file mode 100644 index 00000000..d386aa33 --- /dev/null +++ b/experimental/puppeteer-firefox/test/frame.spec.js @@ -0,0 +1,137 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Frame Management', function() { + it('should handle nested frames', async({page, server}) => { + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + expect(utils.dumpFrames(page.mainFrame())).toEqual([ + 'http://localhost:/frames/nested-frames.html', + ' http://localhost:/frames/two-frames.html (2frames)', + ' http://localhost:/frames/frame.html (uno)', + ' http://localhost:/frames/frame.html (dos)', + ' http://localhost:/frames/frame.html (aframe)' + ]); + }); + it('should send events when frames are manipulated dynamically', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + // validate frameattached events + const attachedFrames = []; + page.on('frameattached', frame => attachedFrames.push(frame)); + await utils.attachFrame(page, 'frame1', '/frames/frame.html'); + expect(attachedFrames.length).toBe(1); + expect(attachedFrames[0].url()).toContain('/frames/frame.html'); + + // validate framenavigated events + const navigatedFrames = []; + page.on('framenavigated', frame => navigatedFrames.push(frame)); + await utils.navigateFrame(page, 'frame1', server.PREFIX + '/empty2.html'); + expect(navigatedFrames.length).toBe(1); + expect(navigatedFrames[0].url()).toBe(server.PREFIX + '/empty2.html'); + + // validate framedetached events + const detachedFrames = []; + page.on('framedetached', frame => detachedFrames.push(frame)); + await utils.detachFrame(page, 'frame1'); + expect(detachedFrames.length).toBe(1); + expect(detachedFrames[0].isDetached()).toBe(true); + }); + it('should send "framenavigated" when navigating on anchor URLs', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await Promise.all([ + page.goto(server.EMPTY_PAGE + '#foo'), + utils.waitEvent(page, 'framenavigated') + ]); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + }); + it('should not send attach/detach events for main frame', async({page, server}) => { + let hasEvents = false; + page.on('frameattached', frame => hasEvents = true); + page.on('framedetached', frame => hasEvents = true); + await page.goto(server.EMPTY_PAGE); + expect(hasEvents).toBe(false); + }); + it('should detach child frames on navigation', async({page, server}) => { + let attachedFrames = []; + let detachedFrames = []; + let navigatedFrames = []; + page.on('frameattached', frame => attachedFrames.push(frame)); + page.on('framedetached', frame => detachedFrames.push(frame)); + page.on('framenavigated', frame => navigatedFrames.push(frame)); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + expect(attachedFrames.length).toBe(4); + expect(detachedFrames.length).toBe(0); + expect(navigatedFrames.length).toBe(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames.length).toBe(0); + expect(detachedFrames.length).toBe(4); + expect(navigatedFrames.length).toBe(1); + }); + it('should support framesets', async({page, server}) => { + let attachedFrames = []; + let detachedFrames = []; + let navigatedFrames = []; + page.on('frameattached', frame => attachedFrames.push(frame)); + page.on('framedetached', frame => detachedFrames.push(frame)); + page.on('framenavigated', frame => navigatedFrames.push(frame)); + await page.goto(server.PREFIX + '/frames/frameset.html'); + expect(attachedFrames.length).toBe(4); + expect(detachedFrames.length).toBe(0); + expect(navigatedFrames.length).toBe(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames.length).toBe(0); + expect(detachedFrames.length).toBe(4); + expect(navigatedFrames.length).toBe(1); + }); + it('should report frame.name()', async({page, server}) => { + await utils.attachFrame(page, 'theFrameId', server.EMPTY_PAGE2); + await page.evaluate(url => { + const frame = document.createElement('iframe'); + frame.name = 'theFrameName'; + frame.src = url; + document.body.appendChild(frame); + return new Promise(x => frame.onload = x); + }, server.EMPTY_PAGE); + expect(page.frames()[0].name()).toBe(''); + expect(page.frames()[1].name()).toBe('theFrameId'); + expect(page.frames()[2].name()).toBe('theFrameName'); + }); + it('should report frame.parent()', async({page, server}) => { + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + expect(page.frames()[0].parentFrame()).toBe(null); + expect(page.frames()[1].parentFrame()).toBe(page.mainFrame()); + expect(page.frames()[2].parentFrame()).toBe(page.mainFrame()); + }); + }); + +}; diff --git a/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-0.png b/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-0.png new file mode 100644 index 00000000..248fdb34 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-0.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-1.png b/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-1.png new file mode 100644 index 00000000..b0483e92 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/grid-cell-1.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-odd-size.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-odd-size.png new file mode 100644 index 00000000..b010d1f8 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-odd-size.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-rect.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-rect.png new file mode 100644 index 00000000..64f8e792 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-clip-rect.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-bounding-box.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-bounding-box.png new file mode 100644 index 00000000..32e05bf0 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-bounding-box.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-larger-than-viewport.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-larger-than-viewport.png new file mode 100644 index 00000000..5fcdb923 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-larger-than-viewport.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-padding-border.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-padding-border.png new file mode 100644 index 00000000..917dd481 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-padding-border.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-rotate.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-rotate.png new file mode 100644 index 00000000..52e2a0f6 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-rotate.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-scrolled-into-view.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-scrolled-into-view.png new file mode 100644 index 00000000..917dd481 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-element-scrolled-into-view.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-grid-fullpage.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-grid-fullpage.png new file mode 100644 index 00000000..db627293 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-grid-fullpage.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-offscreen-clip.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-offscreen-clip.png new file mode 100644 index 00000000..10ec8a02 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-offscreen-clip.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/screenshot-sanity.png b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-sanity.png new file mode 100644 index 00000000..37d4136d Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/screenshot-sanity.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/transparent.png b/experimental/puppeteer-firefox/test/golden-chromium/transparent.png new file mode 100644 index 00000000..1cf45d86 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/transparent.png differ diff --git a/experimental/puppeteer-firefox/test/golden-chromium/white.jpg b/experimental/puppeteer-firefox/test/golden-chromium/white.jpg new file mode 100644 index 00000000..fb9070de Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-chromium/white.jpg differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-0.png b/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-0.png new file mode 100644 index 00000000..4677bdbc Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-0.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-1.png b/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-1.png new file mode 100644 index 00000000..532dc8db Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/grid-cell-1.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-odd-size.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-odd-size.png new file mode 100644 index 00000000..8e86dc90 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-odd-size.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-rect.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-rect.png new file mode 100644 index 00000000..7a744578 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-clip-rect.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-bounding-box.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-bounding-box.png new file mode 100644 index 00000000..f4e059c3 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-bounding-box.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-larger-than-viewport.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-larger-than-viewport.png new file mode 100644 index 00000000..6d28cddc Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-larger-than-viewport.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-padding-border.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-padding-border.png new file mode 100644 index 00000000..2b72c752 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-padding-border.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-rotate.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-rotate.png new file mode 100644 index 00000000..0a78fb1a Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-rotate.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-scrolled-into-view.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-scrolled-into-view.png new file mode 100644 index 00000000..2b72c752 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-element-scrolled-into-view.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-grid-fullpage.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-grid-fullpage.png new file mode 100644 index 00000000..ac47ec83 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-grid-fullpage.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-offscreen-clip.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-offscreen-clip.png new file mode 100644 index 00000000..791496e5 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-offscreen-clip.png differ diff --git a/experimental/puppeteer-firefox/test/golden-firefox/screenshot-sanity.png b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-sanity.png new file mode 100644 index 00000000..07890a04 Binary files /dev/null and b/experimental/puppeteer-firefox/test/golden-firefox/screenshot-sanity.png differ diff --git a/experimental/puppeteer-firefox/test/golden-utils.js b/experimental/puppeteer-firefox/test/golden-utils.js new file mode 100644 index 00000000..86989ac6 --- /dev/null +++ b/experimental/puppeteer-firefox/test/golden-utils.js @@ -0,0 +1,149 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const fs = require('fs'); +const Diff = require('text-diff'); +const mime = require('mime'); +const PNG = require('pngjs').PNG; +const jpeg = require('jpeg-js'); +const pixelmatch = require('pixelmatch'); + +module.exports = {compare}; + +const GoldenComparators = { + 'image/png': compareImages, + 'image/jpeg': compareImages, + 'text/plain': compareText +}; + + +/** + * @param {?Object} actualBuffer + * @param {!Buffer} expectedBuffer + * @param {!string} mimeType + * @return {?{diff: (!Object:undefined), errorMessage: (string|undefined)}} + */ +function compareImages(actualBuffer, expectedBuffer, mimeType) { + if (!actualBuffer || !(actualBuffer instanceof Buffer)) + return { errorMessage: 'Actual result should be Buffer.' }; + + const actual = mimeType === 'image/png' ? PNG.sync.read(actualBuffer) : jpeg.decode(actualBuffer); + const expected = mimeType === 'image/png' ? PNG.sync.read(expectedBuffer) : jpeg.decode(expectedBuffer); + if (expected.width !== actual.width || expected.height !== actual.height) { + return { + errorMessage: `Sizes differ: expected image ${expected.width}px X ${expected.height}px, but got ${actual.width}px X ${actual.height}px. ` + }; + } + const diff = new PNG({width: expected.width, height: expected.height}); + const count = pixelmatch(expected.data, actual.data, diff.data, expected.width, expected.height, {threshold: 0.1}); + return count > 0 ? { diff: PNG.sync.write(diff) } : null; +} + +/** + * @param {?Object} actual + * @param {!Buffer} expectedBuffer + * @return {?{diff: (!Object:undefined), errorMessage: (string|undefined)}} + */ +function compareText(actual, expectedBuffer) { + if (typeof actual !== 'string') + return { errorMessage: 'Actual result should be string' }; + const expected = expectedBuffer.toString('utf-8'); + if (expected === actual) + return null; + const diff = new Diff(); + const result = diff.main(expected, actual); + diff.cleanupSemantic(result); + let html = diff.prettyHtml(result); + const diffStylePath = path.join(__dirname, 'diffstyle.css'); + html = `` + html; + return { + diff: html, + diffExtension: '.html' + }; +} + +/** + * @param {?Object} actual + * @param {string} goldenName + * @return {!{pass: boolean, message: (undefined|string)}} + */ +function compare(goldenPath, outputPath, actual, goldenName) { + goldenPath = path.normalize(goldenPath); + outputPath = path.normalize(outputPath); + const expectedPath = path.join(goldenPath, goldenName); + const actualPath = path.join(outputPath, goldenName); + + const messageSuffix = 'Output is saved in "' + path.basename(outputPath + '" directory'); + + if (!fs.existsSync(expectedPath)) { + ensureOutputDir(); + fs.writeFileSync(actualPath, actual); + return { + pass: false, + message: goldenName + ' is missing in golden results. ' + messageSuffix + }; + } + const expected = fs.readFileSync(expectedPath); + const mimeType = mime.getType(goldenName); + const comparator = GoldenComparators[mimeType]; + if (!comparator) { + return { + pass: false, + message: 'Failed to find comparator with type ' + mimeType + ': ' + goldenName + }; + } + const result = comparator(actual, expected, mimeType); + if (!result) + return { pass: true }; + ensureOutputDir(); + if (goldenPath === outputPath) { + fs.writeFileSync(addSuffix(actualPath, '-actual'), actual); + } else { + fs.writeFileSync(actualPath, actual); + // Copy expected to the output/ folder for convenience. + fs.writeFileSync(addSuffix(actualPath, '-expected'), expected); + } + if (result.diff) { + const diffPath = addSuffix(actualPath, '-diff', result.diffExtension); + fs.writeFileSync(diffPath, result.diff); + } + + let message = goldenName + ' mismatch!'; + if (result.errorMessage) + message += ' ' + result.errorMessage; + return { + pass: false, + message: message + ' ' + messageSuffix + }; + + function ensureOutputDir() { + if (!fs.existsSync(outputPath)) + fs.mkdirSync(outputPath); + } +} + +/** + * @param {string} filePath + * @param {string} suffix + * @param {string=} customExtension + * @return {string} + */ +function addSuffix(filePath, suffix, customExtension) { + const dirname = path.dirname(filePath); + const ext = path.extname(filePath); + const name = path.basename(filePath, ext); + return path.join(dirname, name + suffix + (customExtension || ext)); +} diff --git a/experimental/puppeteer-firefox/test/hover.spec.js b/experimental/puppeteer-firefox/test/hover.spec.js new file mode 100644 index 00000000..485ab1e2 --- /dev/null +++ b/experimental/puppeteer-firefox/test/hover.spec.js @@ -0,0 +1,37 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Hover', function() { + it('should trigger hover state', async({page, server}) => { + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.hover('#button-6'); + expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-6'); + await page.hover('#button-2'); + expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-2'); + await page.hover('#button-91'); + expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-91'); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/ignorehttpserrors.spec.js b/experimental/puppeteer-firefox/test/ignorehttpserrors.spec.js new file mode 100644 index 00000000..d5ea924d --- /dev/null +++ b/experimental/puppeteer-firefox/test/ignorehttpserrors.spec.js @@ -0,0 +1,57 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product, puppeteer}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('ignoreHTTPSErrors', function() { + beforeAll(async state => { + const options = Object.assign({ignoreHTTPSErrors: true}, state.defaultBrowserOptions); + state.browser = await puppeteer.launch(options); + }); + afterAll(async state => { + await state.browser.close(); + delete state.browser; + }); + beforeEach(async state => { + state.page = await state.browser.newPage(); + }); + afterEach(async state => { + await state.page.close(); + delete state.page; + }); + it('should work', async({page, httpsServer}) => { + let error = null; + await page.goto(httpsServer.EMPTY_PAGE).catch(e => error = e); + expect(error).toBe(null); + }); + it('should work with mixed content', async({page, server, httpsServer}) => { + httpsServer.setRoute('/mixedcontent.html', (req, res) => { + res.end(``); + }); + await page.goto(httpsServer.PREFIX + '/mixedcontent.html', {waitUntil: 'load'}); + expect(page.frames().length).toBe(2); + // Make sure blocked iframe has functional execution context + // @see https://github.com/GoogleChrome/puppeteer/issues/2709 + expect(await page.frames()[0].evaluate('1 + 2')).toBe(3); + expect(await page.frames()[1].evaluate('2 + 3')).toBe(5); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/jshandle.spec.js b/experimental/puppeteer-firefox/test/jshandle.spec.js new file mode 100644 index 00000000..c4845bb3 --- /dev/null +++ b/experimental/puppeteer-firefox/test/jshandle.spec.js @@ -0,0 +1,157 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.evaluateHandle', function() { + it('should work', async({page, server}) => { + const windowHandle = await page.evaluateHandle(() => window); + expect(windowHandle).toBeTruthy(); + }); + it('should accept object handle as an argument', async({page, server}) => { + const navigatorHandle = await page.evaluateHandle(() => navigator); + const text = await page.evaluate(e => e.userAgent, navigatorHandle); + expect(text).toContain('Mozilla'); + }); + it('should accept object handle to primitive types', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => 5); + const isFive = await page.evaluate(e => Object.is(e, 5), aHandle); + expect(isFive).toBeTruthy(); + }); + it('should accept object handle to unserializable value', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => Infinity); + expect(await page.evaluate(e => Object.is(e, Infinity), aHandle)).toBe(true); + }); + it('should use the same JS wrappers', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => { + window.FOO = 123; + return window; + }); + expect(await page.evaluate(e => e.FOO, aHandle)).toBe(123); + }); + it('should work with primitives', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => { + window.FOO = 123; + return window; + }); + expect(await page.evaluate(e => e.FOO, aHandle)).toBe(123); + }); + }); + + describe('JSHandle.jsonValue', function() { + it('should work', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => ({foo: 'bar'})); + const json = await aHandle.jsonValue(); + expect(json).toEqual({foo: 'bar'}); + }); + // This relies on Chrome's internal serialization. We can either repeat it + // in FFOX, or migrate to JSON.stringify() logic altogether. + (FFOX ? xit : it)('should not work with dates', async({page, server}) => { + const dateHandle = await page.evaluateHandle(() => new Date('2017-09-26T00:00:00.000Z')); + const json = await dateHandle.jsonValue(); + expect(json).toEqual({}); + }); + it('should throw for circular objects', async({page, server}) => { + const windowHandle = await page.evaluateHandle('window'); + let error = null; + await windowHandle.jsonValue().catch(e => error = e); + expect(error).not.toBe(null); + }); + }); + + describe('JSHandle.toString', function() { + it('should work for primitives', async({page, server}) => { + const numberHandle = await page.evaluateHandle(() => 2); + expect(numberHandle.toString()).toBe('JSHandle:2'); + const stringHandle = await page.evaluateHandle(() => 'a'); + expect(stringHandle.toString()).toBe('JSHandle:a'); + }); + it('should work for complicated objects', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => window); + expect(aHandle.toString()).toBe('JSHandle@object'); + }); + it('should work with different subtypes', async({page, server}) => { + expect((await page.evaluateHandle('(function(){})')).toString()).toBe('JSHandle@function'); + expect((await page.evaluateHandle('12')).toString()).toBe('JSHandle:12'); + expect((await page.evaluateHandle('true')).toString()).toBe('JSHandle:true'); + expect((await page.evaluateHandle('undefined')).toString()).toBe('JSHandle:undefined'); + expect((await page.evaluateHandle('"foo"')).toString()).toBe('JSHandle:foo'); + expect((await page.evaluateHandle('Symbol()')).toString()).toBe('JSHandle@symbol'); + expect((await page.evaluateHandle('new Map()')).toString()).toBe('JSHandle@map'); + expect((await page.evaluateHandle('new Set()')).toString()).toBe('JSHandle@set'); + expect((await page.evaluateHandle('[]')).toString()).toBe('JSHandle@array'); + expect((await page.evaluateHandle('null')).toString()).toBe('JSHandle:null'); + expect((await page.evaluateHandle('/foo/')).toString()).toBe('JSHandle@regexp'); + expect((await page.evaluateHandle('document.body')).toString()).toBe('JSHandle@node'); + expect((await page.evaluateHandle('new Date()')).toString()).toBe('JSHandle@date'); + expect((await page.evaluateHandle('new WeakMap()')).toString()).toBe('JSHandle@weakmap'); + expect((await page.evaluateHandle('new WeakSet()')).toString()).toBe('JSHandle@weakset'); + expect((await page.evaluateHandle('new Error()')).toString()).toBe('JSHandle@error'); + expect((await page.evaluateHandle('new Int32Array()')).toString()).toBe('JSHandle@typedarray'); + expect((await page.evaluateHandle('new Proxy({}, {})')).toString()).toBe('JSHandle@proxy'); + }); + }); + + describe('JSHandle.getProperties', function() { + it('should work', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => ({ + foo: 'bar' + })); + const properties = await aHandle.getProperties(); + const foo = properties.get('foo'); + expect(foo).toBeTruthy(); + expect(await foo.jsonValue()).toBe('bar'); + }); + it('should return even non-own properties', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => { + class A { + constructor() { + this.a = '1'; + } + } + class B extends A { + constructor() { + super(); + this.b = '2'; + } + } + return new B(); + }); + const properties = await aHandle.getProperties(); + expect(await properties.get('a').jsonValue()).toBe('1'); + expect(await properties.get('b').jsonValue()).toBe('2'); + }); + }); + + describe('JSHandle.getProperty', function() { + it('should work', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => ({ + one: 1, + two: 2, + three: 3 + })); + const twoHandle = await aHandle.getProperty('two'); + expect(await twoHandle.jsonValue()).toEqual(2); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/keyboard.spec.js b/experimental/puppeteer-firefox/test/keyboard.spec.js new file mode 100644 index 00000000..9a7b5653 --- /dev/null +++ b/experimental/puppeteer-firefox/test/keyboard.spec.js @@ -0,0 +1,193 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); +const os = require('os'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Keyboard', function() { + it('should type into a textarea', async({page, server}) => { + await page.evaluate(() => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + }); + const text = 'Hello world. I am the text that was typed!'; + await page.keyboard.type(text); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe(text); + }); + it('should type emoji', async({page, server}) => { + await page.evaluate(() => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + }); + await page.keyboard.type('👹 Tokyo street Japan 🇯🇵'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should type emoji', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', '👹 Tokyo street Japan 🇯🇵'); + expect(await page.$eval('textarea', textarea => textarea.value)).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should type emoji into an iframe', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'emoji-test', server.PREFIX + '/input/textarea.html'); + const frame = page.frames()[1]; + const textarea = await frame.$('textarea'); + await textarea.type('👹 Tokyo street Japan 🇯🇵'); + expect(await frame.$eval('textarea', textarea => textarea.value)).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should send a character with sendCharacter', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.keyboard.sendCharacter('嗨'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('嗨'); + await page.evaluate(() => window.addEventListener('keydown', e => e.preventDefault(), true)); + await page.keyboard.sendCharacter('a'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('嗨a'); + }); + it('should press the metaKey', async ({page}) => { + await page.evaluate(() => { + window.keyPromise = new Promise(resolve => document.addEventListener('keydown', event => resolve(event.key))); + }); + await page.keyboard.press('Meta'); + expect(await page.evaluate('keyPromise')).toBe(FFOX && os.platform() !== 'darwin' ? 'OS' : 'Meta'); + }); + xit('should report shiftKey', async({page, server}) => { + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + const codeForKey = {'Shift': 16, 'Alt': 18, 'Meta': 91, 'Control': 17}; + // Firefox has no Meta modifier on Windows/Linux + if (FFOX && os.platform() !== 'darwin') + delete codeForKey['Meta']; + for (const modifierKey in codeForKey) { + await keyboard.down(modifierKey); + expect(await page.evaluate(() => getResult())).toBe('Keydown: ' + modifierKey + ' ' + modifierKey + 'Left ' + codeForKey[modifierKey] + ' [' + modifierKey + ']'); + await keyboard.down('!'); + // Shift+! will generate a keypress + if (modifierKey === 'Shift') + expect(await page.evaluate(() => getResult())).toBe('Keydown: ! Digit1 49 [' + modifierKey + ']\nKeypress: ! Digit1 33 33 [' + modifierKey + ']'); + else + expect(await page.evaluate(() => getResult())).toBe('Keydown: ! Digit1 49 [' + modifierKey + ']'); + + await keyboard.up('!'); + expect(await page.evaluate(() => getResult())).toBe('Keyup: ! Digit1 49 [' + modifierKey + ']'); + await keyboard.up(modifierKey); + expect(await page.evaluate(() => getResult())).toBe('Keyup: ' + modifierKey + ' ' + modifierKey + 'Left ' + codeForKey[modifierKey] + ' []'); + } + }); + xit('should report multiple modifiers', async({page, server}) => { + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Control'); + expect(await page.evaluate(() => getResult())).toBe('Keydown: Control ControlLeft 17 [Control]'); + await keyboard.down('Alt'); + expect(await page.evaluate(() => getResult())).toBe('Keydown: Alt AltLeft 18 [Alt Control]'); + await keyboard.down(';'); + expect(await page.evaluate(() => getResult())).toBe('Keydown: ; Semicolon 186 [Alt Control]'); + await keyboard.up(';'); + expect(await page.evaluate(() => getResult())).toBe('Keyup: ; Semicolon 186 [Alt Control]'); + await keyboard.up('Control'); + expect(await page.evaluate(() => getResult())).toBe('Keyup: Control ControlLeft 17 [Alt]'); + await keyboard.up('Alt'); + expect(await page.evaluate(() => getResult())).toBe('Keyup: Alt AltLeft 18 []'); + }); + it('should send proper codes while typing', async({page, server}) => { + await page.goto(server.PREFIX + '/input/keyboard.html'); + await page.keyboard.type('!'); + expect(await page.evaluate(() => getResult())).toBe( + [ 'Keydown: ! Digit1 49 []', + 'Keypress: ! Digit1 33 33 []', + 'Keyup: ! Digit1 49 []'].join('\n')); + await page.keyboard.type('^'); + expect(await page.evaluate(() => getResult())).toBe( + [ 'Keydown: ^ Digit6 54 []', + 'Keypress: ^ Digit6 94 94 []', + 'Keyup: ^ Digit6 54 []'].join('\n')); + }); + it('should send proper codes while typing with shift', async({page, server}) => { + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Shift'); + await page.keyboard.type('~'); + expect(await page.evaluate(() => getResult())).toBe( + [ 'Keydown: Shift ShiftLeft 16 [Shift]', + 'Keydown: ~ Backquote 192 [Shift]', // 192 is ` keyCode + 'Keypress: ~ Backquote 126 126 [Shift]', // 126 is ~ charCode + 'Keyup: ~ Backquote 192 [Shift]'].join('\n')); + await keyboard.up('Shift'); + }); + it('should not type canceled events', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => { + window.addEventListener('keydown', event => { + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.key === 'l') + event.preventDefault(); + if (event.key === 'o') + event.preventDefault(); + }, false); + }); + await page.keyboard.type('Hello World!'); + expect(await page.evaluate(() => textarea.value)).toBe('He Wrd!'); + }); + it('should specify repeat property', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => document.querySelector('textarea').addEventListener('keydown', e => window.lastEvent = e, true)); + await page.keyboard.down('a'); + expect(await page.evaluate(() => window.lastEvent.repeat)).toBe(false); + await page.keyboard.press('a'); + expect(await page.evaluate(() => window.lastEvent.repeat)).toBe(true); + + await page.keyboard.down('b'); + expect(await page.evaluate(() => window.lastEvent.repeat)).toBe(false); + await page.keyboard.down('b'); + expect(await page.evaluate(() => window.lastEvent.repeat)).toBe(true); + + await page.keyboard.up('a'); + await page.keyboard.down('a'); + expect(await page.evaluate(() => window.lastEvent.repeat)).toBe(false); + }); + it('should type all kinds of characters', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = 'This text goes onto two lines.\nThis character is 嗨.'; + await page.keyboard.type(text); + expect(await page.evaluate('result')).toBe(text); + }); + it('should throw on unknown keys', async({page, server}) => { + let error = await page.keyboard.press('NotARealKey').catch(e => e); + expect(error.message).toBe('Unknown key: "NotARealKey"'); + + error = await page.keyboard.press('ё').catch(e => e); + expect(error && error.message).toBe('Unknown key: "ё"'); + + error = await page.keyboard.press('😊').catch(e => e); + expect(error && error.message).toBe('Unknown key: "😊"'); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/launcher.spec.js b/experimental/puppeteer-firefox/test/launcher.spec.js new file mode 100644 index 00000000..f96fb556 --- /dev/null +++ b/experimental/puppeteer-firefox/test/launcher.spec.js @@ -0,0 +1,56 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const fs = require('fs'); + +module.exports.addTests = function({testRunner, expect, product, puppeteer}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + describe('Launcher.executablePath', function() { + it('should work', async() => { + const executablePath = puppeteer.executablePath({product}); + expect(fs.existsSync(executablePath)).toBe(true); + }); + }); + + describe('Launcher.launch', () => { + it('should set the default viewport', async({defaultBrowserOptions}) => { + const options = Object.assign({}, defaultBrowserOptions, { + defaultViewport: { + width: 456, + height: 789 + } + }); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(await page.evaluate('window.innerWidth')).toBe(456); + expect(await page.evaluate('window.innerHeight')).toBe(789); + await browser.close(); + }); + it('should disable the default viewport', async({defaultBrowserOptions}) => { + const options = Object.assign({}, defaultBrowserOptions, { + defaultViewport: null + }); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(page.viewport()).toBe(null); + await browser.close(); + }); + }); +} diff --git a/experimental/puppeteer-firefox/test/mouse.spec.js b/experimental/puppeteer-firefox/test/mouse.spec.js new file mode 100644 index 00000000..5d0312fd --- /dev/null +++ b/experimental/puppeteer-firefox/test/mouse.spec.js @@ -0,0 +1,108 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Mouse', function() { + it('should click the document', async({page, server}) => { + await page.evaluate(() => { + window.clickPromise = new Promise(resolve => { + document.addEventListener('click', event => { + resolve({ + type: event.type, + detail: event.detail, + clientX: event.clientX, + clientY: event.clientY, + isTrusted: event.isTrusted, + button: event.button + }); + }); + }); + }); + await page.mouse.click(50, 60); + const event = await page.evaluate(() => window.clickPromise); + expect(event.type).toBe('click'); + expect(event.detail).toBe(1); + expect(event.clientX).toBe(50); + expect(event.clientY).toBe(60); + expect(event.isTrusted).toBe(true); + expect(event.button).toBe(0); + }); + it('should resize the textarea', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + const {x, y, width, height} = await page.evaluate(dimensions); + const mouse = page.mouse; + await mouse.move(x + width - 4, y + height - 4); + await mouse.down(); + await mouse.move(x + width + 100, y + height + 100); + await mouse.up(); + const newDimensions = await page.evaluate(dimensions); + expect(newDimensions.width).toBe(Math.round(width + 104)); + expect(newDimensions.height).toBe(Math.round(height + 104)); + }); + it('should select the text with mouse', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = 'This is the text that we are going to try to select. Let\'s see how it goes.'; + await page.keyboard.type(text); + // Firefox needs an extra frame here after typing or it will fail to set the scrollTop + await page.evaluate(() => new Promise(requestAnimationFrame)); + await page.evaluate(() => document.querySelector('textarea').scrollTop = 0); + const {x, y} = await page.evaluate(dimensions); + await page.mouse.move(x + 2,y + 2); + await page.mouse.down(); + await page.mouse.move(200,100); + await page.mouse.up(); + expect(await page.evaluate(() => { + const textarea = document.querySelector('textarea'); + return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd); + })).toBe(text); + }); + it('should tween mouse movement', async({page, server}) => { + await page.mouse.move(100, 100); + await page.evaluate(() => { + window.result = []; + document.addEventListener('mousemove', event => { + window.result.push([event.clientX, event.clientY]); + }); + }); + await page.mouse.move(200, 300, {steps: 5}); + expect(await page.evaluate('result')).toEqual([ + [120, 140], + [140, 180], + [160, 220], + [180, 260], + [200, 300] + ]); + }); + }); +}; + +function dimensions() { + const rect = document.querySelector('textarea').getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height + }; +} diff --git a/experimental/puppeteer-firefox/test/navigation.spec.js b/experimental/puppeteer-firefox/test/navigation.spec.js new file mode 100644 index 00000000..2029e5c5 --- /dev/null +++ b/experimental/puppeteer-firefox/test/navigation.spec.js @@ -0,0 +1,285 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.goto', function () { + it('should work', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with anchor navigation', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goto(server.EMPTY_PAGE + '#foo'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + await page.goto(server.EMPTY_PAGE + '#bar'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#bar'); + }); + it('should navigate to about:blank', async({page, server}) => { + await page.goto('about:blank'); + expect(page.url()).toBe('about:blank'); + }); + it('should work with redirects', async({page, server}) => { + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/empty.html'); + await page.goto(server.PREFIX + '/redirect/1.html'); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with subframes return 204', async({page, server}) => { + server.setRoute('/frames/frame.html', (req, res) => { + res.statusCode = 204; + res.end(); + }); + await page.goto(server.PREFIX + '/frames/one-frame.html'); + }); + it('should fail when server returns 204', async({page, server}) => { + server.setRoute('/empty.html', (req, res) => { + res.statusCode = 204; + res.end(); + }); + let error = null; + await page.goto(server.EMPTY_PAGE).catch(e => error = e); + expect(error).not.toBe(null); + expect(error.message).toContain('ABORTED'); + }); + it('should work when page calls history API in beforeunload', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + window.addEventListener('beforeunload', () => history.replaceState(null, 'initial', window.location.href), false); + }); + await page.goto(server.PREFIX + '/grid.html'); + }); + it('should fail when navigating to bad url', async({page, server}) => { + let error = null; + await page.goto('asdfasdf').catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should fail when navigating to bad url', async({page, server}) => { + let error = null; + await page.goto('asdf').catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should fail when navigating to bad SSL', async({page, httpsServer}) => { + let error = null; + await page.goto(httpsServer.EMPTY_PAGE).catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should fail when navigating to bad SSL after redirects', async({page, server, httpsServer}) => { + httpsServer.setRedirect('/redirect/1.html', '/redirect/2.html'); + httpsServer.setRedirect('/redirect/2.html', '/empty.html'); + let error = null; + await page.goto(httpsServer.PREFIX + '/redirect/1.html').catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should fail when main resources failed to load', async({page, server}) => { + let error = null; + await page.goto('http://localhost:44123/non-existing-url').catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should fail when exceeding maximum navigation timeout', async({page, server}) => { + // Hang for request to the empty.html + server.setRoute('/empty.html', (req, res) => { }); + let error = null; + await page.goto(server.PREFIX + '/empty.html', {timeout: 1}).catch(e => error = e); + expect(error.message).toContain('Navigation Timeout Exceeded: 1ms'); + }); + it('should disable timeout when its set to 0', async({page, server}) => { + await page.goto(server.PREFIX + '/grid.html', {timeout: 0}); + }); + it('should work when navigating to data url', async({page, server}) => { + await page.goto('data:text/html,hello'); + }); + it('should work when navigating to 404', async({page, server}) => { + await page.goto(server.PREFIX + '/not-found'); + }); + it('should not leak listeners during navigation', async({page, server}) => { + let warning = null; + const warningHandler = w => warning = w; + process.on('warning', warningHandler); + for (let i = 0; i < 20; ++i) + await page.goto(server.EMPTY_PAGE); + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should fail when navigating and show the url at the error message', async function({page, server, httpsServer}) { + const url = httpsServer.PREFIX + '/redirect/1.html'; + let error = null; + try { + await page.goto(url); + } catch (e) { + error = e; + } + expect(error.message).toContain(url); + }); + }); + + describe('Page.waitForNavigation', function() { + it('should work', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.evaluate(url => window.location.href = url, server.PREFIX + '/grid.html') + ]); + expect(page.url()).toBe(server.PREFIX + '/grid.html'); + }); + it('should work with both domcontentloaded and load', async({page, server}) => { + let response = null; + server.setRoute('/one-style.css', (req, res) => response = res); + const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); + const domContentLoadedPromise = page.waitForNavigation({ + waitUntil: 'domcontentloaded' + }); + + let bothFired = false; + const bothFiredPromise = page.waitForNavigation({ + waitUntil: ['load', 'domcontentloaded'] + }).then(() => bothFired = true); + + await server.waitForRequest('/one-style.css'); + await domContentLoadedPromise; + expect(bothFired).toBe(false); + response.end(); + await bothFiredPromise; + await navigationPromise; + }); + it('should work with clicking on anchor links', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(`foobar`); + await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foobar'); + }); + it('should work with history.pushState()', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + SPA + + `); + await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(page.url()).toBe(server.PREFIX + '/wow.html'); + }); + it('should work with history.replaceState()', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + SPA + + `); + await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(page.url()).toBe(server.PREFIX + '/replaced.html'); + }); + xit('should work with DOM history.back()/history.forward()', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + back + forward + + `); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + await Promise.all([ + page.waitForNavigation(), + page.click('a#back'), + ]); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + await Promise.all([ + page.waitForNavigation(), + page.click('a#forward'), + ]); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + }); + it('should work when subframe issues window.stop()', async({page, server}) => { + server.setRoute('/frames/style.css', (req, res) => {}); + const navigationPromise = page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = await utils.waitEvent(page, 'frameattached'); + await new Promise(fulfill => { + page.on('framenavigated', f => { + if (f === frame) + fulfill(); + }); + }); + await Promise.all([ + frame.evaluate(() => window.stop()), + navigationPromise + ]); + }); + }); + + describe('Page.goBack + Page.goForward', function() { + it('should work', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/grid.html'); + + await page.goBack(); + expect(page.url()).toBe(server.EMPTY_PAGE); + + await page.goForward(); + expect(page.url()).toContain('/grid.html'); + + await page.goForward(); + expect(page.url()).toContain('/grid.html'); + }); + it('should work with HistoryAPI', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + history.pushState({}, '', '/first.html'); + history.pushState({}, '', '/second.html'); + }); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + + await page.goBack(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + await page.goBack(); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goForward(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + }); + }); + + describe('Page.reload', function() { + it('should work', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => window._foo = 10); + await page.reload(); + expect(await page.evaluate(() => window._foo)).toBe(undefined); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/page.spec.js b/experimental/puppeteer-firefox/test/page.spec.js new file mode 100644 index 00000000..9e6e8ff3 --- /dev/null +++ b/experimental/puppeteer-firefox/test/page.spec.js @@ -0,0 +1,404 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); +const path = require('path'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.Events.DOMContentLoaded', function() { + it('should fire when expected', async({page, server}) => { + await Promise.all([ + page.goto('about:blank'), + utils.waitEvent(page, 'domcontentloaded'), + ]); + }); + }); + describe('Page.Events.Load', function() { + it('should fire when expected', async({page, server}) => { + await Promise.all([ + page.goto('about:blank'), + utils.waitEvent(page, 'load'), + ]); + }); + }); + + describe('Page.Events.PageError', function() { + it('should fire', async({page, server}) => { + let error = null; + page.once('pageerror', e => error = e); + await Promise.all([ + page.goto(server.PREFIX + '/error.html'), + utils.waitEvent(page, 'pageerror') + ]); + expect(error.message).toContain('Fancy'); + }); + }); + + + describe('Page.close', function() { + it('should reject all promises when page is closed', async({browser}) => { + const newPage = await browser.newPage(); + const neverResolves = newPage.evaluate(() => new Promise(r => {})); + newPage.close(); + let error = null; + await neverResolves.catch(e => error = e); + expect(error).not.toBe(null); + }); + it('should not be visible in browser.pages', async({browser}) => { + const newPage = await browser.newPage(); + expect(await browser.pages()).toContain(newPage); + await newPage.close(); + expect(await browser.pages()).not.toContain(newPage); + }); + it('should set the page close state', async({browser}) => { + const newPage = await browser.newPage(); + expect(newPage.isClosed()).toBe(false); + await newPage.close(); + expect(newPage.isClosed()).toBe(true); + }); + it('should emit the close event', async({browser}) => { + const newPage = await browser.newPage(); + let gotClosedEvent = false; + newPage.on('close', () => gotClosedEvent = true); + await newPage.close(); + expect(gotClosedEvent).toBe(true); + }); + }); + + describe('Page.Events.Console', function() { + it('should work', async({page, server}) => { + let message = null; + page.once('console', m => message = m); + await Promise.all([ + page.evaluate(() => console.log('hello', 5, {foo: 'bar'})), + utils.waitEvent(page, 'console') + ]); + expect(message.text()).toEqual('hello 5 JSHandle@object'); + expect(message.type()).toEqual('log'); + expect(await message.args()[0].jsonValue()).toEqual('hello'); + expect(await message.args()[1].jsonValue()).toEqual(5); + expect(await message.args()[2].jsonValue()).toEqual({foo: 'bar'}); + }); + it('should work for different console API calls', async({page, server}) => { + const messages = []; + page.on('console', msg => messages.push(msg)); + // All console events will be reported before `page.evaluate` is finished. + await page.evaluate(() => { + // A pair of time/timeEnd generates only one Console API call. + console.time('calling console.time'); + console.timeEnd('calling console.time'); + console.trace('calling console.trace'); + console.dir('calling console.dir'); + console.warn('calling console.warn'); + console.error('calling console.error'); + console.log(Promise.resolve('should not wait until resolved!')); + }); + expect(messages.map(msg => msg.type())).toEqual([ + 'timeEnd', 'trace', 'dir', 'warning', 'error', 'log' + ]); + expect(messages[0].text()).toContain('calling console.time'); + expect(messages.slice(1).map(msg => msg.text())).toEqual([ + 'calling console.trace', + 'calling console.dir', + 'calling console.warn', + 'calling console.error', + 'JSHandle@promise', + ]); + }); + it('should not fail for window object', async({page, server}) => { + let message = null; + page.once('console', msg => message = msg); + await Promise.all([ + page.evaluate(() => console.error(window)), + utils.waitEvent(page, 'console') + ]); + expect(message.text()).toBe('JSHandle@object'); + }); + }); + + describe('Page.content / Page.setContent', function() { + const expectedOutput = '
hello
'; + it('should work', async({page, server}) => { + await page.setContent('
hello
'); + const result = await page.content(); + expect(result).toBe(expectedOutput); + }); + it('should work with doctype', async({page, server}) => { + const doctype = ''; + await page.setContent(`${doctype}
hello
`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + it('should work with HTML 4 doctype', async({page, server}) => { + const doctype = ''; + await page.setContent(`${doctype}
hello
`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + }); + + describe('Page.addScriptTag', function() { + it('should throw an error if no options are provided', async({page, server}) => { + let error = null; + try { + await page.addScriptTag('/injectedfile.js'); + } catch (e) { + error = e; + } + expect(error.message).toBe('Provide an object with a `url`, `path` or `content` property'); + }); + + it('should work with a url', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ url: '/injectedfile.js' }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => __injected)).toBe(42); + }); + + it('should work with a url and type=module', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ url: '/es6/es6import.js', type: 'module' }); + expect(await page.evaluate(() => __es6injected)).toBe(42); + }); + + it('should work with a path and type=module', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ path: path.join(__dirname, 'assets/es6/es6pathimport.js'), type: 'module' }); + await page.waitForFunction('window.__es6injected'); + expect(await page.evaluate(() => __es6injected)).toBe(42); + }); + + it('should work with a content and type=module', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ content: `import num from '/es6/es6module.js';window.__es6injected = num;`, type: 'module' }); + await page.waitForFunction('window.__es6injected'); + expect(await page.evaluate(() => __es6injected)).toBe(42); + }); + + it('should throw an error if loading from url fail', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + let error = null; + try { + await page.addScriptTag({ url: '/nonexistfile.js' }); + } catch (e) { + error = e; + } + expect(error.message).toBe('Loading script from /nonexistfile.js failed'); + }); + + it('should work with a path', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ path: path.join(__dirname, 'assets/injectedfile.js') }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => __injected)).toBe(42); + }); + + it('should include sourcemap when path is provided', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ path: path.join(__dirname, 'assets/injectedfile.js') }); + const result = await page.evaluate(() => __injectedError.stack); + expect(result).toContain(path.join('assets', 'injectedfile.js')); + }); + + it('should work with content', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ content: 'window.__injected = 35;' }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => __injected)).toBe(35); + }); + + xit('should throw when added with content to the CSP page', async({page, server}) => { + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page.addScriptTag({ content: 'window.__injected = 35;' }).catch(e => error = e); + expect(error).toBeTruthy(); + }); + + it('should throw when added with URL to the CSP page', async({page, server}) => { + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page.addScriptTag({ url: server.CROSS_PROCESS_PREFIX + '/injectedfile.js' }).catch(e => error = e); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.addStyleTag', function() { + it('should throw an error if no options are provided', async({page, server}) => { + let error = null; + try { + await page.addStyleTag('/injectedstyle.css'); + } catch (e) { + error = e; + } + expect(error.message).toBe('Provide an object with a `url`, `path` or `content` property'); + }); + + it('should work with a url', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ url: '/injectedstyle.css' }); + expect(styleHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(`window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')`)).toBe('rgb(255, 0, 0)'); + }); + + it('should throw an error if loading from url fail', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + let error = null; + try { + await page.addStyleTag({ url: '/nonexistfile.js' }); + } catch (e) { + error = e; + } + expect(error.message).toBe('Loading style from /nonexistfile.js failed'); + }); + + it('should work with a path', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ path: path.join(__dirname, 'assets/injectedstyle.css') }); + expect(styleHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(`window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')`)).toBe('rgb(255, 0, 0)'); + }); + + it('should include sourcemap when path is provided', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await page.addStyleTag({ path: path.join(__dirname, 'assets/injectedstyle.css') }); + const styleHandle = await page.$('style'); + const styleContent = await page.evaluate(style => style.innerHTML, styleHandle); + expect(styleContent).toContain(path.join('assets', 'injectedstyle.css')); + }); + + it('should work with content', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ content: 'body { background-color: green; }' }); + expect(styleHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(`window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')`)).toBe('rgb(0, 128, 0)'); + }); + + xit('should throw when added with content to the CSP page', async({page, server}) => { + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page.addStyleTag({ content: 'body { background-color: green; }' }).catch(e => error = e); + expect(error).toBeTruthy(); + }); + + it('should throw when added with URL to the CSP page', async({page, server}) => { + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page.addStyleTag({ url: server.CROSS_PROCESS_PREFIX + '/injectedstyle.css' }).catch(e => error = e); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.title', function() { + it('should return the page title', async({page, server}) => { + await page.goto(server.PREFIX + '/title.html'); + expect(await page.title()).toBe('Woof-Woof'); + }); + }); + + describe('Page.select', function() { + it('should select single option', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect(await page.evaluate(() => result.onInput)).toEqual(['blue']); + expect(await page.evaluate(() => result.onChange)).toEqual(['blue']); + }); + it('should select only first option', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue', 'green', 'red'); + expect(await page.evaluate(() => result.onInput)).toEqual(['blue']); + expect(await page.evaluate(() => result.onChange)).toEqual(['blue']); + }); + it('should select multiple options', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => makeMultiple()); + await page.select('select', 'blue', 'green', 'red'); + expect(await page.evaluate(() => result.onInput)).toEqual(['blue', 'green', 'red']); + expect(await page.evaluate(() => result.onChange)).toEqual(['blue', 'green', 'red']); + }); + it('should respect event bubbling', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect(await page.evaluate(() => result.onBubblingInput)).toEqual(['blue']); + expect(await page.evaluate(() => result.onBubblingChange)).toEqual(['blue']); + }); + it('should throw when element is not a element.'); + }); + it('should return [] on no matched values', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select','42','abc'); + expect(result).toEqual([]); + }); + it('should return an array of matched values', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => makeMultiple()); + const result = await page.select('select','blue','black','magenta'); + expect(result.reduce((accumulator,current) => ['blue', 'black', 'magenta'].includes(current) && accumulator, true)).toEqual(true); + }); + it('should return an array of one element when multiple is not set', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select','42','blue','black','magenta'); + expect(result.length).toEqual(1); + }); + it('should return [] on no values',async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select'); + expect(result).toEqual([]); + }); + it('should deselect all options when passed no values for a multiple select',async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => makeMultiple()); + await page.select('select','blue','black','magenta'); + await page.select('select'); + expect(await page.$eval('select', select => Array.from(select.options).every(option => !option.selected))).toEqual(true); + }); + it('should deselect all options when passed no values for a select without multiple',async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select','blue','black','magenta'); + await page.select('select'); + expect(await page.$eval('select', select => Array.from(select.options).every(option => !option.selected))).toEqual(true); + }); + it('should throw if passed in non-strings', async({page, server}) => { + await page.setContent(''); + let error = null; + try { + await page.select('select', 12); + } catch (e) { + error = e; + } + expect(error.message).toContain('Values must be strings'); + }); + // @see https://github.com/GoogleChrome/puppeteer/issues/3327 + xit('should work when re-defining top-level Event class', async({page, server}) => { + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => window.Event = null); + await page.select('select', 'blue'); + expect(await page.evaluate(() => result.onInput)).toEqual(['blue']); + expect(await page.evaluate(() => result.onChange)).toEqual(['blue']); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/puppeteer.spec.js b/experimental/puppeteer-firefox/test/puppeteer.spec.js new file mode 100644 index 00000000..0cee7815 --- /dev/null +++ b/experimental/puppeteer-firefox/test/puppeteer.spec.js @@ -0,0 +1,96 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const GoldenUtils = require('./golden-utils'); +const {Matchers} = require('@pptr/testrunner'); + +module.exports.addTests = ({testRunner, product, puppeteer}) => testRunner.describe(product, () => { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const GOLDEN_DIR = path.join(__dirname, 'golden-' + product); + const OUTPUT_DIR = path.join(__dirname, 'output-' + product); + const {expect} = new Matchers({ + toBeGolden: GoldenUtils.compare.bind(null, GOLDEN_DIR, OUTPUT_DIR) + }); + + beforeAll(state => { + state.defaultBrowserOptions = { + handleSIGINT: false, + dumpio: (process.env.DUMPIO || 'false').trim().toLowerCase() === 'true', + args: product === 'chromium' ? ['--no-sandbox'] : [], + }; + }); + afterAll(state => { + state.defaultBrowserOptions = undefined; + }); + + require('./launcher.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./ignorehttpserrors.spec.js').addTests({testRunner, expect, product, puppeteer}); + + describe('Browser', () => { + beforeAll(async state => { + state.browser = await puppeteer.launch(state.defaultBrowserOptions); + }); + + afterAll(async state => { + await state.browser.close(); + state.browser = null; + }); + + require('./browser.spec.js').addTests({testRunner, expect, product, puppeteer}); + + describe('Page', () => { + beforeEach(async state => { + state.page = await state.browser.newPage(); + }); + + afterEach(async state => { + await state.page.close(); + state.page = null; + }); + + require('./page.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./evaluation.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./navigation.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./dialog.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./frame.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./jshandle.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./elementhandle.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./target.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./waittask.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./queryselector.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./emulation.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./screenshot.spec.js').addTests({testRunner, expect, product, puppeteer}); + + // Input tests + require('./keyboard.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./mouse.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./click.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./type.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./hover.spec.js').addTests({testRunner, expect, product, puppeteer}); + + // Browser-specific page tests + if (product === 'firefox') + require('./firefoxonly.spec.js').addTests({testRunner, expect, product, puppeteer}); + else + require('./chromiumonly.spec.js').addTests({testRunner, expect, product, puppeteer}); + }); + }); +}); + + diff --git a/experimental/puppeteer-firefox/test/queryselector.spec.js b/experimental/puppeteer-firefox/test/queryselector.spec.js new file mode 100644 index 00000000..36ed6969 --- /dev/null +++ b/experimental/puppeteer-firefox/test/queryselector.spec.js @@ -0,0 +1,194 @@ +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.$', function() { + it('should query existing element', async({page, server}) => { + await page.setContent('
test
'); + const element = await page.$('section'); + expect(element).toBeTruthy(); + }); + it('should return null for non-existing element', async({page, server}) => { + const element = await page.$('non-existing-element'); + expect(element).toBe(null); + }); + }); + + describe('Page.$$', function() { + it('should query existing elements', async({page, server}) => { + await page.setContent('
A

B
'); + const elements = await page.$$('div'); + expect(elements.length).toBe(2); + const promises = elements.map(element => page.evaluate(e => e.textContent, element)); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + it('should return empty array if nothing is found', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const elements = await page.$$('div'); + expect(elements.length).toBe(0); + }); + }); + + describe('Page.$eval', function() { + it('should work', async({page, server}) => { + await page.setContent('
43543
'); + const idAttribute = await page.$eval('section', e => e.id); + expect(idAttribute).toBe('testAttribute'); + }); + it('should accept arguments', async({page, server}) => { + await page.setContent('
hello
'); + const text = await page.$eval('section', (e, suffix) => e.textContent + suffix, ' world!'); + expect(text).toBe('hello world!'); + }); + it('should accept ElementHandles as arguments', async({page, server}) => { + await page.setContent('
hello
world
'); + const divHandle = await page.$('div'); + const text = await page.$eval('section', (e, div) => e.textContent + div.textContent, divHandle); + expect(text).toBe('hello world'); + }); + it('should throw error if no element is found', async({page, server}) => { + let error = null; + await page.$eval('section', e => e.id).catch(e => error = e); + expect(error.message).toContain('failed to find element matching selector "section"'); + }); + }); + + describe('Page.$$eval', function() { + it('should work', async({page, server}) => { + await page.setContent('
hello
beautiful
world!
'); + const divsCount = await page.$$eval('div', divs => divs.length); + expect(divsCount).toBe(3); + }); + }); + + describe('Page.$x', function() { + it('should query existing element', async({page, server}) => { + await page.setContent('
test
'); + const elements = await page.$x('/html/body/section'); + expect(elements[0]).toBeTruthy(); + expect(elements.length).toBe(1); + }); + it('should return empty array for non-existing element', async({page, server}) => { + const element = await page.$x('/html/body/non-existing-element'); + expect(element).toEqual([]); + }); + it('should return multiple elements', async({page, sever}) => { + await page.setContent('
'); + const elements = await page.$x('/html/body/div'); + expect(elements.length).toBe(2); + }); + }); + + describe('ElementHandle.$', function() { + it('should query existing element', async({page, server}) => { + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent('
A
'); + const html = await page.$('html'); + const second = await html.$('.second'); + const inner = await second.$('.inner'); + const content = await page.evaluate(e => e.textContent, inner); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async({page, server}) => { + await page.setContent('
B
'); + const html = await page.$('html'); + const second = await html.$('.third'); + expect(second).toBe(null); + }); + }); + + describe('ElementHandle.$$', function() { + it('should query existing elements', async({page, server}) => { + await page.setContent('
A

B
'); + const html = await page.$('html'); + const elements = await html.$$('div'); + expect(elements.length).toBe(2); + const promises = elements.map(element => page.evaluate(e => e.textContent, element)); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + + it('should return empty array for non-existing elements', async({page, server}) => { + await page.setContent('A
B'); + const html = await page.$('html'); + const elements = await html.$$('div'); + expect(elements.length).toBe(0); + }); + }); + + describe('ElementHandle.$eval', function() { + it('should work', async({page, server}) => { + await page.setContent('
10
'); + const tweet = await page.$('.tweet'); + const content = await tweet.$eval('.like', node => node.innerText); + expect(content).toBe('100'); + }); + + it('should retrieve content from subtree', async({page, server}) => { + const htmlContent = '
not-a-child-div
a-child-div
'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const content = await elementHandle.$eval('.a', node => node.innerText); + expect(content).toBe('a-child-div'); + }); + + it('should throw in case of missing selector', async({page, server}) => { + const htmlContent = '
not-a-child-div
'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const errorMessage = await elementHandle.$eval('.a', node => node.innerText).catch(error => error.message); + expect(errorMessage).toBe(`Error: failed to find element matching selector ".a"`); + }); + }); + + describe('ElementHandle.$$eval', function() { + it('should work', async({page, server}) => { + await page.setContent('
'); + const tweet = await page.$('.tweet'); + const content = await tweet.$$eval('.like', nodes => nodes.map(n => n.innerText)); + expect(content).toEqual(['100', '10']); + }); + + it('should retrieve content from subtree', async({page, server}) => { + const htmlContent = '
not-a-child-div
a1-child-div
a2-child-div
'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const content = await elementHandle.$$eval('.a', nodes => nodes.map(n => n.innerText)); + expect(content).toEqual(['a1-child-div', 'a2-child-div']); + }); + + it('should not throw in case of missing selector', async({page, server}) => { + const htmlContent = '
not-a-child-div
'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const nodesLength = await elementHandle.$$eval('.a', nodes => nodes.length); + expect(nodesLength).toBe(0); + }); + + }); + + describe('ElementHandle.$x', function() { + it('should query existing element', async({page, server}) => { + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent('
A
'); + const html = await page.$('html'); + const second = await html.$x(`./body/div[contains(@class, 'second')]`); + const inner = await second[0].$x(`./div[contains(@class, 'inner')]`); + const content = await page.evaluate(e => e.textContent, inner[0]); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async({page, server}) => { + await page.setContent('
B
'); + const html = await page.$('html'); + const second = await html.$x(`/div[contains(@class, 'third')]`); + expect(second).toEqual([]); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/run_static_server.js b/experimental/puppeteer-firefox/test/run_static_server.js new file mode 100755 index 00000000..9c6b766e --- /dev/null +++ b/experimental/puppeteer-firefox/test/run_static_server.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const {TestServer} = require('@pptr/testserver'); + +const port = 8907; +const httpsPort = 8908; +const assetsPath = path.join(__dirname, 'assets'); + +Promise.all([ + TestServer.create(assetsPath, port), + TestServer.createHTTPS(assetsPath, httpsPort) +]).then(([server, httpsServer]) => { + console.log(`HTTP: server is running on http://localhost:${port}`); + console.log(`HTTPS: server is running on https://localhost:${httpsPort}`); +}); diff --git a/experimental/puppeteer-firefox/test/screenshot.spec.js b/experimental/puppeteer-firefox/test/screenshot.spec.js new file mode 100644 index 00000000..b13ebd0f --- /dev/null +++ b/experimental/puppeteer-firefox/test/screenshot.spec.js @@ -0,0 +1,232 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.screenshot', function() { + it('should work', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + }); + it('should clip rect', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 100, + width: 150, + height: 100 + } + }); + expect(screenshot).toBeGolden('screenshot-clip-rect.png'); + }); + it('should work for offscreen clip', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 600, + width: 100, + height: 100 + } + }); + expect(screenshot).toBeGolden('screenshot-offscreen-clip.png'); + }); + it('should run in parallel', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const promises = []; + for (let i = 0; i < 3; ++i) { + promises.push(page.screenshot({ + clip: { + x: 50 * i, + y: 0, + width: 50, + height: 50 + } + })); + } + const screenshots = await Promise.all(promises); + expect(screenshots[1]).toBeGolden('grid-cell-1.png'); + }); + it('should take fullPage screenshots', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true + }); + expect(screenshot).toBeGolden('screenshot-grid-fullpage.png'); + }); + it('should run in parallel in multiple pages', async({page, server, browser}) => { + const N = 2; + const pages = await Promise.all(Array(N).fill(0).map(async() => { + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + return page; + })); + const promises = []; + for (let i = 0; i < N; ++i) + promises.push(pages[i].screenshot({ clip: { x: 50 * i, y: 0, width: 50, height: 50 } })); + const screenshots = await Promise.all(promises); + for (let i = 0; i < N; ++i) + expect(screenshots[i]).toBeGolden(`grid-cell-${i}.png`); + await Promise.all(pages.map(page => page.close())); + }); + (FFOX ? xit : it)('should allow transparency', async({page, server}) => { + await page.setViewport({ width: 100, height: 100 }); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({omitBackground: true}); + expect(screenshot).toBeGolden('transparent.png'); + }); + (FFOX ? xit : it)('should render white background on jpeg file', async({page, server}) => { + await page.setViewport({ width: 100, height: 100 }); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({omitBackground: true, type: 'jpeg'}); + expect(screenshot).toBeGolden('white.jpg'); + }); + it('should work with odd clip size on Retina displays', async({page, server}) => { + const screenshot = await page.screenshot({ + clip: { + x: 0, + y: 0, + width: 11, + height: 11, + } + }); + expect(screenshot).toBeGolden('screenshot-clip-odd-size.png'); + }); + it('should return base64', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + encoding: 'base64' + }); + expect(Buffer.isBuffer(screenshot)).toBe(false); + expect(Buffer.from(screenshot, 'base64')).toBeGolden('screenshot-sanity.png'); + }); + }); + + describe('ElementHandle.screenshot', function() { + it('should work', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.goto(server.PREFIX + '/grid.html'); + await page.evaluate(() => window.scrollBy(50, 100)); + const elementHandle = await page.$('.box:nth-of-type(3)'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-bounding-box.png'); + }); + it('should take into account padding and border', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + something above + +
+ `); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-padding-border.png'); + }); + it('should capture full element when larger than viewport', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + + await page.setContent(` + something above + +
+ `); + const elementHandle = await page.$('div.to-screenshot'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-larger-than-viewport.png'); + + expect(await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }))).toEqual({ w: 500, h: 500 }); + }); + it('should scroll element into view', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + something above + +
+
+ `); + const elementHandle = await page.$('div.to-screenshot'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-scrolled-into-view.png'); + }); + it('should work with a rotated element', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + await page.setContent(`
 
`); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-rotate.png'); + }); + it('should fail to screenshot a detached element', async({page, server}) => { + await page.setContent('

remove this

'); + const elementHandle = await page.$('h1'); + await page.evaluate(element => element.remove(), elementHandle); + const screenshotError = await elementHandle.screenshot().catch(error => error); + expect(screenshotError.message).toBe('Node is either not visible or not an HTMLElement'); + }); + (CHROME ? xit : it)('should not hang with zero width/height element', async({page, server}) => { + await page.setContent('
'); + const div = await page.$('div'); + await div.screenshot(); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/target.spec.js b/experimental/puppeteer-firefox/test/target.spec.js new file mode 100644 index 00000000..8d9bd32c --- /dev/null +++ b/experimental/puppeteer-firefox/test/target.spec.js @@ -0,0 +1,100 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const utils = require('./utils'); +const {waitEvent} = utils; +const {TimeoutError} = require('../Errors'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + describe('Target', function() { + it('Browser.targets should return all of the targets', async({page, server, browser}) => { + // The pages will be the testing page and the original newtab page + const targets = browser.targets(); + expect(targets.some(target => target.type() === 'page' && + target.url() === 'about:blank')).toBeTruthy('Missing blank page'); + }); + it('Browser.pages should return all of the pages', async({page, server, browser}) => { + // The pages will be the testing page + const allPages = await browser.pages(); + expect(allPages.length).toBe(2); + expect(allPages).toContain(page); + expect(allPages[0]).not.toBe(allPages[1]); + }); + it('should be able to use the default page in the browser', async({page, server, browser}) => { + // The pages will be the testing page and the original newtab page + const allPages = await browser.pages(); + const originalPage = allPages.find(p => p !== page); + expect(await originalPage.evaluate(() => ['Hello', 'world'].join(' '))).toBe('Hello world'); + expect(await originalPage.$('body')).toBeTruthy(); + }); + it('should report when a new page is created and closed', async({page, server, browser}) => { + const [otherPage] = await Promise.all([ + browser.waitForTarget(target => target.url() === server.EMPTY_PAGE2).then(target => target.page()), + page.evaluate(url => window.open(url), server.EMPTY_PAGE2), + ]); + + expect(await otherPage.evaluate(() => ['Hello', 'world'].join(' '))).toBe('Hello world'); + expect(await otherPage.$('body')).toBeTruthy(); + + let allPages = await browser.pages(); + expect(allPages).toContain(page); + expect(allPages).toContain(otherPage); + + const closePagePromise = new Promise(fulfill => browser.once('targetdestroyed', target => fulfill(target.page()))); + await otherPage.close(); + expect(await closePagePromise).toBe(otherPage); + + allPages = await Promise.all(browser.targets().map(target => target.page())); + expect(allPages).toContain(page); + expect(allPages).not.toContain(otherPage); + }); + it('should report when a target url changes', async({page, server, browser}) => { + await page.goto(server.EMPTY_PAGE); + let changedTarget = new Promise(fulfill => browser.once('targetchanged', target => fulfill(target))); + await page.goto(server.EMPTY_PAGE2); + expect((await changedTarget).url()).toBe(server.EMPTY_PAGE2); + + changedTarget = new Promise(fulfill => browser.once('targetchanged', target => fulfill(target))); + await page.goto(server.EMPTY_PAGE); + expect((await changedTarget).url()).toBe(server.EMPTY_PAGE); + }); + }); + + describe('Browser.waitForTarget', () => { + it('should wait for a target', async function({browser, server}) { + let resolved = false; + const targetPromise = browser.waitForTarget(target => target.url() === server.EMPTY_PAGE2); + targetPromise.then(() => resolved = true); + const page = await browser.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE2); + const target = await targetPromise; + expect(await target.page()).toBe(page); + await page.close(); + }); + it('should timeout waiting for a non-existent target', async function({browser, server}) { + let error = null; + await browser.waitForTarget(target => target.url() === server.EMPTY_PAGE2, { + timeout: 1 + }).catch(e => error = e); + expect(error).toBeInstanceOf(TimeoutError); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/test/test.js b/experimental/puppeteer-firefox/test/test.js new file mode 100644 index 00000000..3f9471fd --- /dev/null +++ b/experimental/puppeteer-firefox/test/test.js @@ -0,0 +1,80 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const {TestServer} = require('@pptr/testserver'); +const {TestRunner, Reporter} = require('@pptr/testrunner'); + +let parallel = 1; +const parallelArgIndex = process.argv.indexOf('-j'); +if (parallelArgIndex !== -1) + parallel = parseInt(process.argv[parallelArgIndex + 1], 10); +require('events').defaultMaxListeners *= parallel; + +let timeout = 10000; +if (!isNaN(process.env.TIMEOUT)) + timeout = parseInt(process.env.TIMEOUT, 10); +const testRunner = new TestRunner({timeout, parallel}); + +console.log('Testing on Node', process.version); + +testRunner.beforeAll(async state => { + const assetsPath = path.join(__dirname, 'assets'); + + const port = 8907 + state.parallelIndex * 2; + state.server = await TestServer.create(assetsPath, port); + state.server.PORT = port; + state.server.PREFIX = `http://localhost:${port}`; + state.server.EMPTY_PAGE = `http://localhost:${port}/empty.html`; + state.server.EMPTY_PAGE2 = `http://localhost:${port}/empty2.html`; + + const httpsPort = port + 1; + state.httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort); + state.httpsServer.PORT = httpsPort; + state.httpsServer.PREFIX = `https://localhost:${httpsPort}`; + state.httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`; + state.httpsServer.EMPTY_PAGE2 = `https://localhost:${httpsPort}/empty2.html`; +}); + +testRunner.afterAll(async({server, httpsServer}) => { + await Promise.all([ + server.stop(), + httpsServer.stop(), + ]); +}); + +testRunner.beforeEach(async({server, httpsServer}) => { + server.reset(); + httpsServer.reset(); +}); + +const product = process.env['PRODUCT']; +const pptrFirefox = require('..'); +const pptrChromium = require('puppeteer'); +if (product) { + console.log(`WARNING: Running with ${product} only product because of PRODUCT env`); + require('./puppeteer.spec.js').addTests({testRunner, product, puppeteer: product === 'chromium' ? pptrChromium : pptrFirefox}); +} else { + require('./puppeteer.spec.js').addTests({testRunner, product: 'chromium', puppeteer: pptrChromium}); + require('./puppeteer.spec.js').addTests({testRunner, product: 'firefox', puppeteer: pptrFirefox}); +} + +if (process.env.CI && testRunner.hasFocusedTestsOrSuites()) { + console.error('ERROR: "focused" tests/suites are prohibitted on bots. Remove any "fit"/"fdescribe" declarations.'); + process.exit(1); +} + +new Reporter(testRunner, path.join(__dirname, '..')); +testRunner.run(); diff --git a/experimental/puppeteer-firefox/test/type.spec.js b/experimental/puppeteer-firefox/test/type.spec.js new file mode 100644 index 00000000..fea75ad3 --- /dev/null +++ b/experimental/puppeteer-firefox/test/type.spec.js @@ -0,0 +1,84 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.type', () => { + it('should type into the textarea', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + + const textarea = await page.$('textarea'); + await textarea.type('Type in this text!'); + expect(await page.evaluate(() => result)).toBe('Type in this text!'); + }); + it('should move with the arrow keys', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', 'Hello World!'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('Hello World!'); + for (let i = 0; i < 'World!'.length; i++) + page.keyboard.press('ArrowLeft'); + await page.keyboard.type('inserted '); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('Hello inserted World!'); + page.keyboard.down('Shift'); + for (let i = 0; i < 'inserted '.length; i++) + page.keyboard.press('ArrowLeft'); + page.keyboard.up('Shift'); + await page.keyboard.press('Backspace'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('Hello World!'); + }); + }); + + describe('ElementHandle.press', () => { + it('should send a character with ElementHandle.press', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + const textarea = await page.$('textarea'); + await textarea.press('a'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('a'); + + await page.evaluate(() => window.addEventListener('keydown', e => e.preventDefault(), true)); + + await textarea.press('a'); + expect(await page.evaluate(() => document.querySelector('textarea').value)).toBe('a'); + }); + + it('should specify location', async({page, server}) => { + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.evaluate(() => { + window.addEventListener('keydown', event => window.keyLocation = event.location, true); + }); + const textarea = await page.$('textarea'); + + await textarea.press('Digit5'); + expect(await page.evaluate('keyLocation')).toBe(0); + + await textarea.press('ControlLeft'); + expect(await page.evaluate('keyLocation')).toBe(1); + + await textarea.press('ControlRight'); + expect(await page.evaluate('keyLocation')).toBe(2); + + await textarea.press('NumpadSubtract'); + expect(await page.evaluate('keyLocation')).toBe(3); + }); + }); +}; + diff --git a/experimental/puppeteer-firefox/test/utils.js b/experimental/puppeteer-firefox/test/utils.js new file mode 100644 index 00000000..872c9767 --- /dev/null +++ b/experimental/puppeteer-firefox/test/utils.js @@ -0,0 +1,98 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +const utils = module.exports = { + /** + * @param {!Page} page + * @param {string} frameId + * @param {string} url + * @return {!Puppeteer.Frame} + */ + attachFrame: async function(page, frameId, url) { + await page.evaluate(attachFrame, frameId, url); + + async function attachFrame(frameId, url) { + const frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise(x => frame.onload = x); + } + }, + + /** + * @param {!Page} page + * @param {string} frameId + */ + detachFrame: async function(page, frameId) { + await page.evaluate(detachFrame, frameId); + + function detachFrame(frameId) { + const frame = document.getElementById(frameId); + frame.remove(); + } + }, + + /** + * @param {!Page} page + * @param {string} frameId + * @param {string} url + */ + navigateFrame: async function(page, frameId, url) { + await page.evaluate(navigateFrame, frameId, url); + + function navigateFrame(frameId, url) { + const frame = document.getElementById(frameId); + frame.src = url; + return new Promise(x => frame.onload = x); + } + }, + + /** + * @param {!Frame} frame + * @param {string=} indentation + * @return {!Array} + */ + dumpFrames: function(frame, indentation) { + indentation = indentation || ''; + let description = frame.url().replace(/:\d{4}\//, ':/'); + if (frame.name()) + description += ' (' + frame.name() + ')'; + let result = [indentation + description]; + for (const child of frame.childFrames()) + result.push(...utils.dumpFrames(child, ' ' + indentation)); + return result; + }, + + /** + * @param {!EventEmitter} emitter + * @param {string} eventName + * @return {!Promise} + */ + waitEvent: function(emitter, eventName, predicate = () => true) { + return new Promise(fulfill => { + emitter.on(eventName, function listener(event) { + if (!predicate(event)) + return; + emitter.removeListener(eventName, listener); + fulfill(event); + }); + }); + }, +}; diff --git a/experimental/puppeteer-firefox/test/waittask.spec.js b/experimental/puppeteer-firefox/test/waittask.spec.js new file mode 100644 index 00000000..b7592fa3 --- /dev/null +++ b/experimental/puppeteer-firefox/test/waittask.spec.js @@ -0,0 +1,425 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const utils = require('./utils'); +const {TimeoutError} = require('../Errors'); + +module.exports.addTests = function({testRunner, expect, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + describe('Page.waitForFunction', function() { + it('should accept a string', async({page, server}) => { + const watchdog = page.waitForFunction('window.__FOO === 1'); + await page.evaluate(() => window.__FOO = 1); + await watchdog; + }); + it('should work when resolved right before execution context disposal', async({page, server}) => { + await page.evaluateOnNewDocument(() => window.__RELOADED = true); + await page.waitForFunction(() => { + if (!window.__RELOADED) + window.location.reload(); + return true; + }); + }); + it('should poll on interval', async({page, server}) => { + let success = false; + const startTime = Date.now(); + const polling = 100; + const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling}) + .then(() => success = true); + await page.evaluate(() => window.__FOO = 'hit'); + expect(success).toBe(false); + await page.evaluate(() => document.body.appendChild(document.createElement('div'))); + await watchdog; + expect(Date.now() - startTime).not.toBeLessThan(polling / 2); + }); + it('should poll on mutation', async({page, server}) => { + let success = false; + const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling: 'mutation'}) + .then(() => success = true); + await page.evaluate(() => window.__FOO = 'hit'); + expect(success).toBe(false); + await page.evaluate(() => document.body.appendChild(document.createElement('div'))); + await watchdog; + }); + it('should poll on raf', async({page, server}) => { + const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling: 'raf'}); + await page.evaluate(() => window.__FOO = 'hit'); + await watchdog; + }); + xit('should work with strict CSP policy', async({page, server}) => { + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling: 'raf'}); + await page.evaluate(() => window.__FOO = 'hit'); + await watchdog; + }); + it('should throw on bad polling value', async({page, server}) => { + let error = null; + try { + await page.waitForFunction(() => !!document.body, {polling: 'unknown'}); + } catch (e) { + error = e; + } + expect(error).toBeTruthy(); + expect(error.message).toContain('polling'); + }); + it('should throw negative polling interval', async({page, server}) => { + let error = null; + try { + await page.waitForFunction(() => !!document.body, {polling: -10}); + } catch (e) { + error = e; + } + expect(error).toBeTruthy(); + expect(error.message).toContain('Cannot poll with non-positive interval'); + }); + it('should return the success value as a JSHandle', async({page}) => { + expect(await (await page.waitForFunction(() => 5)).jsonValue()).toBe(5); + }); + it('should return the window as a success value', async({ page }) => { + expect(await page.waitForFunction(() => window)).toBeTruthy(); + }); + it('should accept ElementHandle arguments', async({page}) => { + await page.setContent('
'); + const div = await page.$('div'); + let resolved = false; + const waitForFunction = page.waitForFunction(element => !element.parentElement, {}, div).then(() => resolved = true); + expect(resolved).toBe(false); + await page.evaluate(element => element.remove(), div); + await waitForFunction; + }); + it('should respect timeout', async({page}) => { + let error = null; + await page.waitForFunction('false', {timeout: 10}).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('waiting for function failed: timeout'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should disable timeout when its set to 0', async({page}) => { + const watchdog = page.waitForFunction(() => { + window.__counter = (window.__counter || 0) + 1; + return window.__injected; + }, {timeout: 0, polling: 10}); + await page.waitForFunction(() => window.__counter > 10); + await page.evaluate(() => window.__injected = true); + await watchdog; + }); + it('should survive navigations', async({page, server}) => { + const watchdog = page.waitForFunction(() => { + return window.__done; + }); + await page.goto(server.EMPTY_PAGE); + await page.goto(server.EMPTY_PAGE2); + await page.evaluate(() => window.__done = true); + await watchdog; + }); + }); + + describe('Page.waitForSelector', function() { + const addElement = tag => document.body.appendChild(document.createElement(tag)); + + it('should immediately resolve promise if node exists', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + await frame.waitForSelector('*'); + await frame.evaluate(addElement, 'div'); + await frame.waitForSelector('div'); + }); + + it('should resolve promise when node is added', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('div'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'div'); + const eHandle = await watchdog; + const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue()); + expect(tagName).toBe('DIV'); + }); + + it('should work when node is added through innerHTML', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('h3 div'); + await page.evaluate(addElement, 'span'); + await page.evaluate(() => document.querySelector('span').innerHTML = '

'); + await watchdog; + }); + + it('Page.waitForSelector is shortcut for main frame', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE2); + const otherFrame = page.frames()[1]; + const watchdog = page.waitForSelector('div'); + await otherFrame.evaluate(addElement, 'div'); + await page.evaluate(addElement, 'div'); + const eHandle = await watchdog; + expect(await page.mainFrame().evaluate(e => !!e, eHandle)).toBe(true); + }); + + it('should run in specified frame', async({page, server}) => { + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForSelectorPromise = frame2.waitForSelector('div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + const eHandle = await waitForSelectorPromise; + expect(await frame2.evaluate(e => !!e, eHandle)).toBe(true); + }); + + it('should throw if evaluation failed', async({page, server}) => { + await page.evaluateOnNewDocument(function() { + document.querySelector = null; + }); + await page.goto(server.EMPTY_PAGE); + let error = null; + await page.waitForSelector('*').catch(e => error = e); + expect(error.message).toContain('document.querySelector is not a function'); + }); + it('should throw when frame is detached', async({page, server}) => { + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame.waitForSelector('.box').catch(e => waitError = e); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain('waitForFunction failed: frame got detached.'); + }); + xit('should survive cross-process navigation', async({page, server}) => { + let boxFound = false; + const waitForSelector = page.waitForSelector('.box').then(() => boxFound = true); + await page.goto(server.EMPTY_PAGE); + expect(boxFound).toBe(false); + await page.reload(); + expect(boxFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(boxFound).toBe(true); + }); + it('should wait for visible', async({page, server}) => { + let divFound = false; + const waitForSelector = page.waitForSelector('div', {visible: true}).then(() => divFound = true); + await page.setContent(`
1
`); + expect(divFound).toBe(false); + await page.evaluate(() => document.querySelector('div').style.removeProperty('display')); + expect(divFound).toBe(false); + await page.evaluate(() => document.querySelector('div').style.removeProperty('visibility')); + expect(await waitForSelector).toBe(true); + expect(divFound).toBe(true); + }); + it('should wait for visible recursively', async({page, server}) => { + let divVisible = false; + const waitForSelector = page.waitForSelector('div#inner', {visible: true}).then(() => divVisible = true); + await page.setContent(`
hi
`); + expect(divVisible).toBe(false); + await page.evaluate(() => document.querySelector('div').style.removeProperty('display')); + expect(divVisible).toBe(false); + await page.evaluate(() => document.querySelector('div').style.removeProperty('visibility')); + expect(await waitForSelector).toBe(true); + expect(divVisible).toBe(true); + }); + it('hidden should wait for visibility: hidden', async({page, server}) => { + let divHidden = false; + await page.setContent(`
`); + const waitForSelector = page.waitForSelector('div', {hidden: true}).then(() => divHidden = true); + await page.waitForSelector('div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => document.querySelector('div').style.setProperty('visibility', 'hidden')); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + it('hidden should wait for display: none', async({page, server}) => { + let divHidden = false; + await page.setContent(`
`); + const waitForSelector = page.waitForSelector('div', {hidden: true}).then(() => divHidden = true); + await page.waitForSelector('div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => document.querySelector('div').style.setProperty('display', 'none')); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + it('hidden should wait for removal', async({page, server}) => { + await page.setContent(`
`); + let divRemoved = false; + const waitForSelector = page.waitForSelector('div', {hidden: true}).then(() => divRemoved = true); + await page.waitForSelector('div'); // do a round trip + expect(divRemoved).toBe(false); + await page.evaluate(() => document.querySelector('div').remove()); + expect(await waitForSelector).toBe(true); + expect(divRemoved).toBe(true); + }); + it('should respect timeout', async({page, server}) => { + let error = null; + await page.waitForSelector('div', {timeout: 10}).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('waiting for selector "div" failed: timeout'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should have an error message specifically for awaiting an element to be hidden', async({page, server}) => { + await page.setContent(`
`); + let error = null; + await page.waitForSelector('div', {hidden: true, timeout: 10}).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('waiting for selector "div" to be hidden failed: timeout'); + }); + + it('should respond to node attribute mutation', async({page, server}) => { + let divFound = false; + const waitForSelector = page.waitForSelector('.zombo').then(() => divFound = true); + await page.setContent(`
`); + expect(divFound).toBe(false); + await page.evaluate(() => document.querySelector('div').className = 'zombo'); + expect(await waitForSelector).toBe(true); + }); + it('should return the element handle', async({page, server}) => { + const waitForSelector = page.waitForSelector('.zombo'); + await page.setContent(`
anything
`); + expect(await page.evaluate(x => x.textContent, await waitForSelector)).toBe('anything'); + }); + it('should have correct stack trace for timeout', async({page, server}) => { + let error; + await page.waitForSelector('.zombo', {timeout: 10}).catch(e => error = e); + expect(error.stack).toContain('waittask.spec.js'); + }); + }); + + describe('Frame.waitForXPath', function() { + const addElement = tag => document.body.appendChild(document.createElement(tag)); + + it('should support some fancy xpath', async({page, server}) => { + await page.setContent(`

red herring

hello world

`); + const waitForXPath = page.waitForXPath('//p[normalize-space(.)="hello world"]'); + expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('hello world '); + }); + it('should respect timeout', async({page}) => { + let error = null; + await page.waitForXPath('//div', {timeout: 10}).catch(e => error = e); + expect(error).toBeTruthy(); + expect(error.message).toContain('waiting for XPath "//div" failed: timeout'); + expect(error).toBeInstanceOf(TimeoutError); + }); + it('should run in specified frame', async({page, server}) => { + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForXPathPromise = frame2.waitForXPath('//div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + const eHandle = await waitForXPathPromise; + expect(await frame2.evaluate(e => !!e, eHandle)).toBe(true); + }); + it('should throw if evaluation failed', async({page, server}) => { + await page.evaluateOnNewDocument(function() { + document.evaluate = null; + }); + await page.goto(server.EMPTY_PAGE); + let error = null; + await page.waitForXPath('*').catch(e => error = e); + expect(error.message).toContain('document.evaluate is not a function'); + }); + it('should throw when frame is detached', async({page, server}) => { + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame.waitForXPath('//*[@class="box"]').catch(e => waitError = e); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain('waitForFunction failed: frame got detached.'); + }); + it('hidden should wait for display: none', async({page, server}) => { + let divHidden = false; + await page.setContent(`
`); + const waitForXPath = page.waitForXPath('//div', {hidden: true}).then(() => divHidden = true); + await page.waitForXPath('//div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => document.querySelector('div').style.setProperty('display', 'none')); + expect(await waitForXPath).toBe(true); + expect(divHidden).toBe(true); + }); + it('should return the element handle', async({page, server}) => { + const waitForXPath = page.waitForXPath('//*[@class="zombo"]'); + await page.setContent(`
anything
`); + expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('anything'); + }); + it('should allow you to select a text node', async({page, server}) => { + await page.setContent(`
some text
`); + const text = await page.waitForXPath('//div/text()'); + expect(await (await text.getProperty('nodeType')).jsonValue()).toBe(3 /* Node.TEXT_NODE */); + }); + it('should allow you to select an element with single slash', async({page, server}) => { + await page.setContent(`
some text
`); + const waitForXPath = page.waitForXPath('/html/body/div'); + expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('some text'); + }); + }); + + describe('Page.waitFor', function() { + it('should wait for selector', async({page, server}) => { + let found = false; + const waitFor = page.waitFor('div').then(() => found = true); + await page.goto(server.EMPTY_PAGE); + expect(found).toBe(false); + await page.goto(server.PREFIX + '/grid.html'); + await waitFor; + expect(found).toBe(true); + }); + it('should wait for an xpath', async({page, server}) => { + let found = false; + const waitFor = page.waitFor('//div').then(() => found = true); + await page.goto(server.EMPTY_PAGE); + expect(found).toBe(false); + await page.goto(server.PREFIX + '/grid.html'); + await waitFor; + expect(found).toBe(true); + }); + it('should not allow you to select an element with single slash xpath', async({page, server}) => { + await page.setContent(`
some text
`); + let error = null; + await page.waitFor('/html/body/div').catch(e => error = e); + expect(error).toBeTruthy(); + }); + it('should timeout', async({page, server}) => { + const startTime = Date.now(); + const timeout = 42; + await page.waitFor(timeout); + expect(Date.now() - startTime).not.toBeLessThan(timeout / 2); + }); + it('should wait for predicate', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + await Promise.all([ + page.waitFor(() => window.location.href.includes('woof')), + page.goto(server.EMPTY_PAGE + '#woof'), + ]); + }); + it('should throw when unknown type', async({page, server}) => { + let error = null; + await page.waitFor({foo: 'bar'}).catch(e => error = e); + expect(error.message).toContain('Unsupported target type'); + }); + it('should wait for predicate with arguments', async({page, server}) => { + await page.waitFor((arg1, arg2) => arg1 !== arg2, {}, 1, 2); + }); + }); +}; diff --git a/experimental/puppeteer-firefox/tsconfig.json b/experimental/puppeteer-firefox/tsconfig.json new file mode 100644 index 00000000..b8254061 --- /dev/null +++ b/experimental/puppeteer-firefox/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "checkJs": true, + "allowJs": true, + "target": "es2017", + "noEmit": true + }, + "include": [ + "lib" + ] +} \ No newline at end of file