feat: introduce puppeteer-firefox (#3628)
This adds a proof-of-concept of `puppeteer-firefox`. This consists of two parts: - `//experimental/juggler` - patches to apply to Firefox. - `//experimental/puppeteer-firefox` - front-end code to be merged with Puppeteer. As things become more stable, we'll gradually move it out of the experimental folder.
@ -6,3 +6,4 @@ utils/testrunner/examples/
|
||||
node6/*
|
||||
node6-test/*
|
||||
node6-testrunner/*
|
||||
experimental/
|
||||
|
8
experimental/README.md
Normal file
@ -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
|
38
experimental/juggler/.cirrus.yml
Normal file
@ -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
|
||||
|
1
experimental/juggler/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
firefox/
|
27
experimental/juggler/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM ubuntu:trusty
|
||||
|
||||
MAINTAINER Andrey Lushnikov <aslushnikov@gmail.com>
|
||||
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
|
1
experimental/juggler/FIREFOX_SHA
Normal file
@ -0,0 +1 @@
|
||||
663997bb1dd09a5d93135b1707feb59024eb9db4
|
77
experimental/juggler/README.md
Normal file
@ -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`
|
||||
|
@ -0,0 +1,160 @@
|
||||
From fb96032ad20cb0dc5fbabe52a80d13d6e6808bb8 Mon Sep 17 00:00:00 2001
|
||||
From: Andrey Lushnikov <lushnikov@chromium.org>
|
||||
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<nsIWebProgressListener2> 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
|
||||
|
@ -0,0 +1,24 @@
|
||||
From c6f975dbc28b902cc271f79dedc42073ab1bde7d Mon Sep 17 00:00:00 2001
|
||||
From: Andrey Lushnikov <lushnikov@chromium.org>
|
||||
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
|
||||
|
@ -0,0 +1,43 @@
|
||||
From 1449495af094fbc5e1bb351f8387c3a341977763 Mon Sep 17 00:00:00 2001
|
||||
From: Andrey Lushnikov <lushnikov@chromium.org>
|
||||
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
|
||||
|
18
experimental/juggler/scripts/fetch_firefox.sh
Executable file
@ -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
|
9
experimental/juggler/scripts/install_gcloud.sh
Executable file
@ -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
|
13
experimental/juggler/scripts/upload_linux.sh
Executable file
@ -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)/
|
13
experimental/juggler/scripts/upload_mac.sh
Executable file
@ -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)/
|
149
experimental/juggler/src/BrowserHandler.jsm
Normal file
@ -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<Ci.nsIDOMChromeWindow>}
|
||||
*/
|
||||
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<Ci.nsIDOMChromeWindow>}
|
||||
*/
|
||||
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;
|
72
experimental/juggler/src/ChromeSession.js
Normal file
@ -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;
|
||||
|
46
experimental/juggler/src/Helper.js
Normal file
@ -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;
|
||||
|
78
experimental/juggler/src/InsecureSweepingOverride.js
Normal file
@ -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;
|
337
experimental/juggler/src/PageHandler.jsm
Normal file
@ -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;
|
371
experimental/juggler/src/Protocol.js
Normal file
@ -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'];
|
63
experimental/juggler/src/components/juggler.js
Normal file
@ -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]);
|
||||
|
3
experimental/juggler/src/components/juggler.manifest
Normal file
@ -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
|
9
experimental/juggler/src/components/moz.build
Normal file
@ -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",
|
||||
]
|
||||
|
53
experimental/juggler/src/content/ContentSession.js
Normal file
@ -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;
|
||||
|
287
experimental/juggler/src/content/FrameTree.js
Normal file
@ -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 '<unknown error>';
|
||||
}
|
||||
|
||||
var EXPORTED_SYMBOLS = ['FrameTree'];
|
||||
this.FrameTree = FrameTree;
|
||||
|
460
experimental/juggler/src/content/PageAgent.js
Normal file
@ -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;
|
||||
|
275
experimental/juggler/src/content/RuntimeAgent.js
Normal file
@ -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;
|
58
experimental/juggler/src/content/ScrollbarManager.js
Normal file
@ -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;
|
||||
|
47
experimental/juggler/src/content/floating-scrollbars.css
Normal file
@ -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 <select> are excluded (including them hides the select arrow on
|
||||
Windows). We want to include both the root scrollbars for the document as
|
||||
well as any overflow: scroll elements within the page, while excluding
|
||||
<select>. */
|
||||
*|*: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;
|
||||
}
|
13
experimental/juggler/src/content/hidden-scrollbars.css
Normal file
@ -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 <select> are excluded (including them hides the select arrow on
|
||||
Windows). We want to include both the root scrollbars for the document as
|
||||
well as any overflow: scroll elements within the page, while excluding
|
||||
<select>. */
|
||||
*|*:not(html|select) > scrollbar {
|
||||
-moz-appearance: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
27
experimental/juggler/src/content/main.js
Normal file
@ -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();
|
||||
}),
|
||||
];
|
||||
|
25
experimental/juggler/src/jar.mn
Normal file
@ -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)
|
||||
|
15
experimental/juggler/src/moz.build
Normal file
@ -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")
|
||||
|
407
experimental/juggler/src/server/packets.js
Normal file
@ -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;
|
||||
},
|
||||
});
|
97
experimental/juggler/src/server/server.js
Normal file
@ -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;
|
247
experimental/juggler/src/server/stream-utils.js
Normal file
@ -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
|
||||
* <var>delimiter</var> character, but stopping if we've read |count|
|
||||
* without finding it. Reading also terminates early if there are less
|
||||
* than <var>count</var> 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,
|
||||
};
|
523
experimental/juggler/src/server/transport.js
Normal file
@ -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;
|
||||
},
|
||||
};
|
17
experimental/puppeteer-firefox/.ci/node6/Dockerfile.linux
Normal file
@ -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
|
17
experimental/puppeteer-firefox/.ci/node8/Dockerfile.linux
Normal file
@ -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
|
11
experimental/puppeteer-firefox/.ci/node8/Dockerfile.windows
Normal file
@ -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'
|
31
experimental/puppeteer-firefox/.cirrus.yml
Normal file
@ -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
|
10
experimental/puppeteer-firefox/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/node_modules/
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.pyc
|
||||
.vscode
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.local-browser
|
||||
/test/output-chromium
|
||||
/test/output-firefox
|
37
experimental/puppeteer-firefox/.npmignore
Normal file
@ -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
|
||||
|
1
experimental/puppeteer-firefox/Errors.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/Errors');
|
202
experimental/puppeteer-firefox/LICENSE
Normal file
@ -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.
|
187
experimental/puppeteer-firefox/README.md
Normal file
@ -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.
|
7
experimental/puppeteer-firefox/completeness.sh
Executable file
@ -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
|
||||
|
27
experimental/puppeteer-firefox/examples/screenshot.js
Normal file
@ -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();
|
||||
})();
|
55
experimental/puppeteer-firefox/examples/search.js
Normal file
@ -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();
|
||||
})();
|
56
experimental/puppeteer-firefox/index.js
Normal file
@ -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();
|
153
experimental/puppeteer-firefox/install.js
Normal file
@ -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<string>}
|
||||
* @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;
|
||||
});
|
||||
}
|
||||
|
29
experimental/puppeteer-firefox/lib/Errors.js
Normal file
@ -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,
|
||||
};
|
20
experimental/puppeteer-firefox/lib/common.js
Normal file
@ -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};
|
171
experimental/puppeteer-firefox/lib/firefox/Browser.js
Normal file
@ -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<string, ?Target>} */
|
||||
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<string>}
|
||||
*/
|
||||
async userAgent() {
|
||||
const info = await this._connection.send('Browser.getInfo');
|
||||
return info.userAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {!Promise<string>}
|
||||
*/
|
||||
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<!Target>}
|
||||
*/
|
||||
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<!Page>} */
|
||||
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};
|
342
experimental/puppeteer-firefox/lib/firefox/BrowserFetcher.js
Normal file
@ -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<boolean>}
|
||||
*/
|
||||
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<!BrowserFetcher.RevisionInfo>}
|
||||
*/
|
||||
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<!Array<string>>}
|
||||
*/
|
||||
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<?Error>}
|
||||
*/
|
||||
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
|
||||
*/
|
123
experimental/puppeteer-firefox/lib/firefox/Connection.js
Normal file
@ -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<number, {resolve: function, reject: function, error: !Error, method: string}>}*/
|
||||
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<?Object>}
|
||||
*/
|
||||
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};
|
57
experimental/puppeteer-firefox/lib/firefox/Dialog.js
Normal file
@ -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};
|
126
experimental/puppeteer-firefox/lib/firefox/FirefoxTransport.js
Normal file
@ -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<!FirefoxTransport>}
|
||||
*/
|
||||
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;
|
295
experimental/puppeteer-firefox/lib/firefox/Input.js
Normal file
@ -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 };
|
206
experimental/puppeteer-firefox/lib/firefox/Launcher.js
Normal file
@ -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<!Browser>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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};
|
1821
experimental/puppeteer-firefox/lib/firefox/Page.js
Normal file
281
experimental/puppeteer-firefox/lib/firefox/USKeyboardLayout.js
Normal file
@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} KeyDefinition
|
||||
* @property {number=} keyCode
|
||||
* @property {number=} shiftKeyCode
|
||||
* @property {string=} key
|
||||
* @property {string=} shiftKey
|
||||
* @property {string=} code
|
||||
* @property {string=} text
|
||||
* @property {string=} shiftText
|
||||
* @property {number=} location
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Object<string, KeyDefinition>}
|
||||
*/
|
||||
module.exports = {
|
||||
'0': {'keyCode': 48, 'key': '0', 'code': 'Digit0'},
|
||||
'1': {'keyCode': 49, 'key': '1', 'code': 'Digit1'},
|
||||
'2': {'keyCode': 50, 'key': '2', 'code': 'Digit2'},
|
||||
'3': {'keyCode': 51, 'key': '3', 'code': 'Digit3'},
|
||||
'4': {'keyCode': 52, 'key': '4', 'code': 'Digit4'},
|
||||
'5': {'keyCode': 53, 'key': '5', 'code': 'Digit5'},
|
||||
'6': {'keyCode': 54, 'key': '6', 'code': 'Digit6'},
|
||||
'7': {'keyCode': 55, 'key': '7', 'code': 'Digit7'},
|
||||
'8': {'keyCode': 56, 'key': '8', 'code': 'Digit8'},
|
||||
'9': {'keyCode': 57, 'key': '9', 'code': 'Digit9'},
|
||||
'Power': {'key': 'Power', 'code': 'Power'},
|
||||
'Eject': {'key': 'Eject', 'code': 'Eject'},
|
||||
'Abort': {'keyCode': 3, 'code': 'Abort', 'key': 'Cancel'},
|
||||
'Help': {'keyCode': 6, 'code': 'Help', 'key': 'Help'},
|
||||
'Backspace': {'keyCode': 8, 'code': 'Backspace', 'key': 'Backspace'},
|
||||
'Tab': {'keyCode': 9, 'code': 'Tab', 'key': 'Tab'},
|
||||
'Numpad5': {'keyCode': 12, 'shiftKeyCode': 101, 'key': 'Clear', 'code': 'Numpad5', 'shiftKey': '5', 'location': 3},
|
||||
'NumpadEnter': {'keyCode': 13, 'code': 'NumpadEnter', 'key': 'Enter', 'text': '\r', 'location': 3},
|
||||
'Enter': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'},
|
||||
'\r': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'},
|
||||
'\n': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'},
|
||||
'ShiftLeft': {'keyCode': 16, 'code': 'ShiftLeft', 'key': 'Shift', 'location': 1},
|
||||
'ShiftRight': {'keyCode': 16, 'code': 'ShiftRight', 'key': 'Shift', 'location': 2},
|
||||
'ControlLeft': {'keyCode': 17, 'code': 'ControlLeft', 'key': 'Control', 'location': 1},
|
||||
'ControlRight': {'keyCode': 17, 'code': 'ControlRight', 'key': 'Control', 'location': 2},
|
||||
'AltLeft': {'keyCode': 18, 'code': 'AltLeft', 'key': 'Alt', 'location': 1},
|
||||
'AltRight': {'keyCode': 18, 'code': 'AltRight', 'key': 'Alt', 'location': 2},
|
||||
'Pause': {'keyCode': 19, 'code': 'Pause', 'key': 'Pause'},
|
||||
'CapsLock': {'keyCode': 20, 'code': 'CapsLock', 'key': 'CapsLock'},
|
||||
'Escape': {'keyCode': 27, 'code': 'Escape', 'key': 'Escape'},
|
||||
'Convert': {'keyCode': 28, 'code': 'Convert', 'key': 'Convert'},
|
||||
'NonConvert': {'keyCode': 29, 'code': 'NonConvert', 'key': 'NonConvert'},
|
||||
'Space': {'keyCode': 32, 'code': 'Space', 'key': ' '},
|
||||
'Numpad9': {'keyCode': 33, 'shiftKeyCode': 105, 'key': 'PageUp', 'code': 'Numpad9', 'shiftKey': '9', 'location': 3},
|
||||
'PageUp': {'keyCode': 33, 'code': 'PageUp', 'key': 'PageUp'},
|
||||
'Numpad3': {'keyCode': 34, 'shiftKeyCode': 99, 'key': 'PageDown', 'code': 'Numpad3', 'shiftKey': '3', 'location': 3},
|
||||
'PageDown': {'keyCode': 34, 'code': 'PageDown', 'key': 'PageDown'},
|
||||
'End': {'keyCode': 35, 'code': 'End', 'key': 'End'},
|
||||
'Numpad1': {'keyCode': 35, 'shiftKeyCode': 97, 'key': 'End', 'code': 'Numpad1', 'shiftKey': '1', 'location': 3},
|
||||
'Home': {'keyCode': 36, 'code': 'Home', 'key': 'Home'},
|
||||
'Numpad7': {'keyCode': 36, 'shiftKeyCode': 103, 'key': 'Home', 'code': 'Numpad7', 'shiftKey': '7', 'location': 3},
|
||||
'ArrowLeft': {'keyCode': 37, 'code': 'ArrowLeft', 'key': 'ArrowLeft'},
|
||||
'Numpad4': {'keyCode': 37, 'shiftKeyCode': 100, 'key': 'ArrowLeft', 'code': 'Numpad4', 'shiftKey': '4', 'location': 3},
|
||||
'Numpad8': {'keyCode': 38, 'shiftKeyCode': 104, 'key': 'ArrowUp', 'code': 'Numpad8', 'shiftKey': '8', 'location': 3},
|
||||
'ArrowUp': {'keyCode': 38, 'code': 'ArrowUp', 'key': 'ArrowUp'},
|
||||
'ArrowRight': {'keyCode': 39, 'code': 'ArrowRight', 'key': 'ArrowRight'},
|
||||
'Numpad6': {'keyCode': 39, 'shiftKeyCode': 102, 'key': 'ArrowRight', 'code': 'Numpad6', 'shiftKey': '6', 'location': 3},
|
||||
'Numpad2': {'keyCode': 40, 'shiftKeyCode': 98, 'key': 'ArrowDown', 'code': 'Numpad2', 'shiftKey': '2', 'location': 3},
|
||||
'ArrowDown': {'keyCode': 40, 'code': 'ArrowDown', 'key': 'ArrowDown'},
|
||||
'Select': {'keyCode': 41, 'code': 'Select', 'key': 'Select'},
|
||||
'Open': {'keyCode': 43, 'code': 'Open', 'key': 'Execute'},
|
||||
'PrintScreen': {'keyCode': 44, 'code': 'PrintScreen', 'key': 'PrintScreen'},
|
||||
'Insert': {'keyCode': 45, 'code': 'Insert', 'key': 'Insert'},
|
||||
'Numpad0': {'keyCode': 45, 'shiftKeyCode': 96, 'key': 'Insert', 'code': 'Numpad0', 'shiftKey': '0', 'location': 3},
|
||||
'Delete': {'keyCode': 46, 'code': 'Delete', 'key': 'Delete'},
|
||||
'NumpadDecimal': {'keyCode': 46, 'shiftKeyCode': 110, 'code': 'NumpadDecimal', 'key': '\u0000', 'shiftKey': '.', 'location': 3},
|
||||
'Digit0': {'keyCode': 48, 'code': 'Digit0', 'shiftKey': ')', 'key': '0'},
|
||||
'Digit1': {'keyCode': 49, 'code': 'Digit1', 'shiftKey': '!', 'key': '1'},
|
||||
'Digit2': {'keyCode': 50, 'code': 'Digit2', 'shiftKey': '@', 'key': '2'},
|
||||
'Digit3': {'keyCode': 51, 'code': 'Digit3', 'shiftKey': '#', 'key': '3'},
|
||||
'Digit4': {'keyCode': 52, 'code': 'Digit4', 'shiftKey': '$', 'key': '4'},
|
||||
'Digit5': {'keyCode': 53, 'code': 'Digit5', 'shiftKey': '%', 'key': '5'},
|
||||
'Digit6': {'keyCode': 54, 'code': 'Digit6', 'shiftKey': '^', 'key': '6'},
|
||||
'Digit7': {'keyCode': 55, 'code': 'Digit7', 'shiftKey': '&', 'key': '7'},
|
||||
'Digit8': {'keyCode': 56, 'code': 'Digit8', 'shiftKey': '*', 'key': '8'},
|
||||
'Digit9': {'keyCode': 57, 'code': 'Digit9', 'shiftKey': '\(', 'key': '9'},
|
||||
'KeyA': {'keyCode': 65, 'code': 'KeyA', 'shiftKey': 'A', 'key': 'a'},
|
||||
'KeyB': {'keyCode': 66, 'code': 'KeyB', 'shiftKey': 'B', 'key': 'b'},
|
||||
'KeyC': {'keyCode': 67, 'code': 'KeyC', 'shiftKey': 'C', 'key': 'c'},
|
||||
'KeyD': {'keyCode': 68, 'code': 'KeyD', 'shiftKey': 'D', 'key': 'd'},
|
||||
'KeyE': {'keyCode': 69, 'code': 'KeyE', 'shiftKey': 'E', 'key': 'e'},
|
||||
'KeyF': {'keyCode': 70, 'code': 'KeyF', 'shiftKey': 'F', 'key': 'f'},
|
||||
'KeyG': {'keyCode': 71, 'code': 'KeyG', 'shiftKey': 'G', 'key': 'g'},
|
||||
'KeyH': {'keyCode': 72, 'code': 'KeyH', 'shiftKey': 'H', 'key': 'h'},
|
||||
'KeyI': {'keyCode': 73, 'code': 'KeyI', 'shiftKey': 'I', 'key': 'i'},
|
||||
'KeyJ': {'keyCode': 74, 'code': 'KeyJ', 'shiftKey': 'J', 'key': 'j'},
|
||||
'KeyK': {'keyCode': 75, 'code': 'KeyK', 'shiftKey': 'K', 'key': 'k'},
|
||||
'KeyL': {'keyCode': 76, 'code': 'KeyL', 'shiftKey': 'L', 'key': 'l'},
|
||||
'KeyM': {'keyCode': 77, 'code': 'KeyM', 'shiftKey': 'M', 'key': 'm'},
|
||||
'KeyN': {'keyCode': 78, 'code': 'KeyN', 'shiftKey': 'N', 'key': 'n'},
|
||||
'KeyO': {'keyCode': 79, 'code': 'KeyO', 'shiftKey': 'O', 'key': 'o'},
|
||||
'KeyP': {'keyCode': 80, 'code': 'KeyP', 'shiftKey': 'P', 'key': 'p'},
|
||||
'KeyQ': {'keyCode': 81, 'code': 'KeyQ', 'shiftKey': 'Q', 'key': 'q'},
|
||||
'KeyR': {'keyCode': 82, 'code': 'KeyR', 'shiftKey': 'R', 'key': 'r'},
|
||||
'KeyS': {'keyCode': 83, 'code': 'KeyS', 'shiftKey': 'S', 'key': 's'},
|
||||
'KeyT': {'keyCode': 84, 'code': 'KeyT', 'shiftKey': 'T', 'key': 't'},
|
||||
'KeyU': {'keyCode': 85, 'code': 'KeyU', 'shiftKey': 'U', 'key': 'u'},
|
||||
'KeyV': {'keyCode': 86, 'code': 'KeyV', 'shiftKey': 'V', 'key': 'v'},
|
||||
'KeyW': {'keyCode': 87, 'code': 'KeyW', 'shiftKey': 'W', 'key': 'w'},
|
||||
'KeyX': {'keyCode': 88, 'code': 'KeyX', 'shiftKey': 'X', 'key': 'x'},
|
||||
'KeyY': {'keyCode': 89, 'code': 'KeyY', 'shiftKey': 'Y', 'key': 'y'},
|
||||
'KeyZ': {'keyCode': 90, 'code': 'KeyZ', 'shiftKey': 'Z', 'key': 'z'},
|
||||
'MetaLeft': {'keyCode': 91, 'code': 'MetaLeft', 'key': 'Meta', 'location': 1},
|
||||
'MetaRight': {'keyCode': 92, 'code': 'MetaRight', 'key': 'Meta', 'location': 2},
|
||||
'ContextMenu': {'keyCode': 93, 'code': 'ContextMenu', 'key': 'ContextMenu'},
|
||||
'NumpadMultiply': {'keyCode': 106, 'code': 'NumpadMultiply', 'key': '*', 'location': 3},
|
||||
'NumpadAdd': {'keyCode': 107, 'code': 'NumpadAdd', 'key': '+', 'location': 3},
|
||||
'NumpadSubtract': {'keyCode': 109, 'code': 'NumpadSubtract', 'key': '-', 'location': 3},
|
||||
'NumpadDivide': {'keyCode': 111, 'code': 'NumpadDivide', 'key': '/', 'location': 3},
|
||||
'F1': {'keyCode': 112, 'code': 'F1', 'key': 'F1'},
|
||||
'F2': {'keyCode': 113, 'code': 'F2', 'key': 'F2'},
|
||||
'F3': {'keyCode': 114, 'code': 'F3', 'key': 'F3'},
|
||||
'F4': {'keyCode': 115, 'code': 'F4', 'key': 'F4'},
|
||||
'F5': {'keyCode': 116, 'code': 'F5', 'key': 'F5'},
|
||||
'F6': {'keyCode': 117, 'code': 'F6', 'key': 'F6'},
|
||||
'F7': {'keyCode': 118, 'code': 'F7', 'key': 'F7'},
|
||||
'F8': {'keyCode': 119, 'code': 'F8', 'key': 'F8'},
|
||||
'F9': {'keyCode': 120, 'code': 'F9', 'key': 'F9'},
|
||||
'F10': {'keyCode': 121, 'code': 'F10', 'key': 'F10'},
|
||||
'F11': {'keyCode': 122, 'code': 'F11', 'key': 'F11'},
|
||||
'F12': {'keyCode': 123, 'code': 'F12', 'key': 'F12'},
|
||||
'F13': {'keyCode': 124, 'code': 'F13', 'key': 'F13'},
|
||||
'F14': {'keyCode': 125, 'code': 'F14', 'key': 'F14'},
|
||||
'F15': {'keyCode': 126, 'code': 'F15', 'key': 'F15'},
|
||||
'F16': {'keyCode': 127, 'code': 'F16', 'key': 'F16'},
|
||||
'F17': {'keyCode': 128, 'code': 'F17', 'key': 'F17'},
|
||||
'F18': {'keyCode': 129, 'code': 'F18', 'key': 'F18'},
|
||||
'F19': {'keyCode': 130, 'code': 'F19', 'key': 'F19'},
|
||||
'F20': {'keyCode': 131, 'code': 'F20', 'key': 'F20'},
|
||||
'F21': {'keyCode': 132, 'code': 'F21', 'key': 'F21'},
|
||||
'F22': {'keyCode': 133, 'code': 'F22', 'key': 'F22'},
|
||||
'F23': {'keyCode': 134, 'code': 'F23', 'key': 'F23'},
|
||||
'F24': {'keyCode': 135, 'code': 'F24', 'key': 'F24'},
|
||||
'NumLock': {'keyCode': 144, 'code': 'NumLock', 'key': 'NumLock'},
|
||||
'ScrollLock': {'keyCode': 145, 'code': 'ScrollLock', 'key': 'ScrollLock'},
|
||||
'AudioVolumeMute': {'keyCode': 173, 'code': 'AudioVolumeMute', 'key': 'AudioVolumeMute'},
|
||||
'AudioVolumeDown': {'keyCode': 174, 'code': 'AudioVolumeDown', 'key': 'AudioVolumeDown'},
|
||||
'AudioVolumeUp': {'keyCode': 175, 'code': 'AudioVolumeUp', 'key': 'AudioVolumeUp'},
|
||||
'MediaTrackNext': {'keyCode': 176, 'code': 'MediaTrackNext', 'key': 'MediaTrackNext'},
|
||||
'MediaTrackPrevious': {'keyCode': 177, 'code': 'MediaTrackPrevious', 'key': 'MediaTrackPrevious'},
|
||||
'MediaStop': {'keyCode': 178, 'code': 'MediaStop', 'key': 'MediaStop'},
|
||||
'MediaPlayPause': {'keyCode': 179, 'code': 'MediaPlayPause', 'key': 'MediaPlayPause'},
|
||||
'Semicolon': {'keyCode': 186, 'code': 'Semicolon', 'shiftKey': ':', 'key': ';'},
|
||||
'Equal': {'keyCode': 187, 'code': 'Equal', 'shiftKey': '+', 'key': '='},
|
||||
'NumpadEqual': {'keyCode': 187, 'code': 'NumpadEqual', 'key': '=', 'location': 3},
|
||||
'Comma': {'keyCode': 188, 'code': 'Comma', 'shiftKey': '\<', 'key': ','},
|
||||
'Minus': {'keyCode': 189, 'code': 'Minus', 'shiftKey': '_', 'key': '-'},
|
||||
'Period': {'keyCode': 190, 'code': 'Period', 'shiftKey': '>', 'key': '.'},
|
||||
'Slash': {'keyCode': 191, 'code': 'Slash', 'shiftKey': '?', 'key': '/'},
|
||||
'Backquote': {'keyCode': 192, 'code': 'Backquote', 'shiftKey': '~', 'key': '`'},
|
||||
'BracketLeft': {'keyCode': 219, 'code': 'BracketLeft', 'shiftKey': '{', 'key': '['},
|
||||
'Backslash': {'keyCode': 220, 'code': 'Backslash', 'shiftKey': '|', 'key': '\\'},
|
||||
'BracketRight': {'keyCode': 221, 'code': 'BracketRight', 'shiftKey': '}', 'key': ']'},
|
||||
'Quote': {'keyCode': 222, 'code': 'Quote', 'shiftKey': '"', 'key': '\''},
|
||||
'AltGraph': {'keyCode': 225, 'code': 'AltGraph', 'key': 'AltGraph'},
|
||||
'Props': {'keyCode': 247, 'code': 'Props', 'key': 'CrSel'},
|
||||
'Cancel': {'keyCode': 3, 'key': 'Cancel', 'code': 'Abort'},
|
||||
'Clear': {'keyCode': 12, 'key': 'Clear', 'code': 'Numpad5', 'location': 3},
|
||||
'Shift': {'keyCode': 16, 'key': 'Shift', 'code': 'ShiftLeft', 'location': 1},
|
||||
'Control': {'keyCode': 17, 'key': 'Control', 'code': 'ControlLeft', 'location': 1},
|
||||
'Alt': {'keyCode': 18, 'key': 'Alt', 'code': 'AltLeft', 'location': 1},
|
||||
'Accept': {'keyCode': 30, 'key': 'Accept'},
|
||||
'ModeChange': {'keyCode': 31, 'key': 'ModeChange'},
|
||||
' ': {'keyCode': 32, 'key': ' ', 'code': 'Space'},
|
||||
'Print': {'keyCode': 42, 'key': 'Print'},
|
||||
'Execute': {'keyCode': 43, 'key': 'Execute', 'code': 'Open'},
|
||||
'\u0000': {'keyCode': 46, 'key': '\u0000', 'code': 'NumpadDecimal', 'location': 3},
|
||||
'a': {'keyCode': 65, 'key': 'a', 'code': 'KeyA'},
|
||||
'b': {'keyCode': 66, 'key': 'b', 'code': 'KeyB'},
|
||||
'c': {'keyCode': 67, 'key': 'c', 'code': 'KeyC'},
|
||||
'd': {'keyCode': 68, 'key': 'd', 'code': 'KeyD'},
|
||||
'e': {'keyCode': 69, 'key': 'e', 'code': 'KeyE'},
|
||||
'f': {'keyCode': 70, 'key': 'f', 'code': 'KeyF'},
|
||||
'g': {'keyCode': 71, 'key': 'g', 'code': 'KeyG'},
|
||||
'h': {'keyCode': 72, 'key': 'h', 'code': 'KeyH'},
|
||||
'i': {'keyCode': 73, 'key': 'i', 'code': 'KeyI'},
|
||||
'j': {'keyCode': 74, 'key': 'j', 'code': 'KeyJ'},
|
||||
'k': {'keyCode': 75, 'key': 'k', 'code': 'KeyK'},
|
||||
'l': {'keyCode': 76, 'key': 'l', 'code': 'KeyL'},
|
||||
'm': {'keyCode': 77, 'key': 'm', 'code': 'KeyM'},
|
||||
'n': {'keyCode': 78, 'key': 'n', 'code': 'KeyN'},
|
||||
'o': {'keyCode': 79, 'key': 'o', 'code': 'KeyO'},
|
||||
'p': {'keyCode': 80, 'key': 'p', 'code': 'KeyP'},
|
||||
'q': {'keyCode': 81, 'key': 'q', 'code': 'KeyQ'},
|
||||
'r': {'keyCode': 82, 'key': 'r', 'code': 'KeyR'},
|
||||
's': {'keyCode': 83, 'key': 's', 'code': 'KeyS'},
|
||||
't': {'keyCode': 84, 'key': 't', 'code': 'KeyT'},
|
||||
'u': {'keyCode': 85, 'key': 'u', 'code': 'KeyU'},
|
||||
'v': {'keyCode': 86, 'key': 'v', 'code': 'KeyV'},
|
||||
'w': {'keyCode': 87, 'key': 'w', 'code': 'KeyW'},
|
||||
'x': {'keyCode': 88, 'key': 'x', 'code': 'KeyX'},
|
||||
'y': {'keyCode': 89, 'key': 'y', 'code': 'KeyY'},
|
||||
'z': {'keyCode': 90, 'key': 'z', 'code': 'KeyZ'},
|
||||
'Meta': {'keyCode': 91, 'key': 'Meta', 'code': 'MetaLeft', 'location': 1},
|
||||
'*': {'keyCode': 106, 'key': '*', 'code': 'NumpadMultiply', 'location': 3},
|
||||
'+': {'keyCode': 107, 'key': '+', 'code': 'NumpadAdd', 'location': 3},
|
||||
'-': {'keyCode': 109, 'key': '-', 'code': 'NumpadSubtract', 'location': 3},
|
||||
'/': {'keyCode': 111, 'key': '/', 'code': 'NumpadDivide', 'location': 3},
|
||||
';': {'keyCode': 186, 'key': ';', 'code': 'Semicolon'},
|
||||
'=': {'keyCode': 187, 'key': '=', 'code': 'Equal'},
|
||||
',': {'keyCode': 188, 'key': ',', 'code': 'Comma'},
|
||||
'.': {'keyCode': 190, 'key': '.', 'code': 'Period'},
|
||||
'`': {'keyCode': 192, 'key': '`', 'code': 'Backquote'},
|
||||
'[': {'keyCode': 219, 'key': '[', 'code': 'BracketLeft'},
|
||||
'\\': {'keyCode': 220, 'key': '\\', 'code': 'Backslash'},
|
||||
']': {'keyCode': 221, 'key': ']', 'code': 'BracketRight'},
|
||||
'\'': {'keyCode': 222, 'key': '\'', 'code': 'Quote'},
|
||||
'Attn': {'keyCode': 246, 'key': 'Attn'},
|
||||
'CrSel': {'keyCode': 247, 'key': 'CrSel', 'code': 'Props'},
|
||||
'ExSel': {'keyCode': 248, 'key': 'ExSel'},
|
||||
'EraseEof': {'keyCode': 249, 'key': 'EraseEof'},
|
||||
'Play': {'keyCode': 250, 'key': 'Play'},
|
||||
'ZoomOut': {'keyCode': 251, 'key': 'ZoomOut'},
|
||||
')': {'keyCode': 48, 'key': ')', 'code': 'Digit0'},
|
||||
'!': {'keyCode': 49, 'key': '!', 'code': 'Digit1'},
|
||||
'@': {'keyCode': 50, 'key': '@', 'code': 'Digit2'},
|
||||
'#': {'keyCode': 51, 'key': '#', 'code': 'Digit3'},
|
||||
'$': {'keyCode': 52, 'key': '$', 'code': 'Digit4'},
|
||||
'%': {'keyCode': 53, 'key': '%', 'code': 'Digit5'},
|
||||
'^': {'keyCode': 54, 'key': '^', 'code': 'Digit6'},
|
||||
'&': {'keyCode': 55, 'key': '&', 'code': 'Digit7'},
|
||||
'(': {'keyCode': 57, 'key': '\(', 'code': 'Digit9'},
|
||||
'A': {'keyCode': 65, 'key': 'A', 'code': 'KeyA'},
|
||||
'B': {'keyCode': 66, 'key': 'B', 'code': 'KeyB'},
|
||||
'C': {'keyCode': 67, 'key': 'C', 'code': 'KeyC'},
|
||||
'D': {'keyCode': 68, 'key': 'D', 'code': 'KeyD'},
|
||||
'E': {'keyCode': 69, 'key': 'E', 'code': 'KeyE'},
|
||||
'F': {'keyCode': 70, 'key': 'F', 'code': 'KeyF'},
|
||||
'G': {'keyCode': 71, 'key': 'G', 'code': 'KeyG'},
|
||||
'H': {'keyCode': 72, 'key': 'H', 'code': 'KeyH'},
|
||||
'I': {'keyCode': 73, 'key': 'I', 'code': 'KeyI'},
|
||||
'J': {'keyCode': 74, 'key': 'J', 'code': 'KeyJ'},
|
||||
'K': {'keyCode': 75, 'key': 'K', 'code': 'KeyK'},
|
||||
'L': {'keyCode': 76, 'key': 'L', 'code': 'KeyL'},
|
||||
'M': {'keyCode': 77, 'key': 'M', 'code': 'KeyM'},
|
||||
'N': {'keyCode': 78, 'key': 'N', 'code': 'KeyN'},
|
||||
'O': {'keyCode': 79, 'key': 'O', 'code': 'KeyO'},
|
||||
'P': {'keyCode': 80, 'key': 'P', 'code': 'KeyP'},
|
||||
'Q': {'keyCode': 81, 'key': 'Q', 'code': 'KeyQ'},
|
||||
'R': {'keyCode': 82, 'key': 'R', 'code': 'KeyR'},
|
||||
'S': {'keyCode': 83, 'key': 'S', 'code': 'KeyS'},
|
||||
'T': {'keyCode': 84, 'key': 'T', 'code': 'KeyT'},
|
||||
'U': {'keyCode': 85, 'key': 'U', 'code': 'KeyU'},
|
||||
'V': {'keyCode': 86, 'key': 'V', 'code': 'KeyV'},
|
||||
'W': {'keyCode': 87, 'key': 'W', 'code': 'KeyW'},
|
||||
'X': {'keyCode': 88, 'key': 'X', 'code': 'KeyX'},
|
||||
'Y': {'keyCode': 89, 'key': 'Y', 'code': 'KeyY'},
|
||||
'Z': {'keyCode': 90, 'key': 'Z', 'code': 'KeyZ'},
|
||||
':': {'keyCode': 186, 'key': ':', 'code': 'Semicolon'},
|
||||
'<': {'keyCode': 188, 'key': '\<', 'code': 'Comma'},
|
||||
'_': {'keyCode': 189, 'key': '_', 'code': 'Minus'},
|
||||
'>': {'keyCode': 190, 'key': '>', 'code': 'Period'},
|
||||
'?': {'keyCode': 191, 'key': '?', 'code': 'Slash'},
|
||||
'~': {'keyCode': 192, 'key': '~', 'code': 'Backquote'},
|
||||
'{': {'keyCode': 219, 'key': '{', 'code': 'BracketLeft'},
|
||||
'|': {'keyCode': 220, 'key': '|', 'code': 'Backslash'},
|
||||
'}': {'keyCode': 221, 'key': '}', 'code': 'BracketRight'},
|
||||
'"': {'keyCode': 222, 'key': '"', 'code': 'Quote'}
|
||||
};
|
28
experimental/puppeteer-firefox/lib/firefox/externs.d.ts
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
import { Connection as RealConnection } from './Connection';
|
||||
import { Target as RealTarget } from './Browser';
|
||||
import * as child_process from 'child_process';
|
||||
declare global {
|
||||
module Puppeteer {
|
||||
|
||||
export interface ConnectionTransport {
|
||||
send(string);
|
||||
close();
|
||||
onmessage?: (message: string) => void,
|
||||
onclose?: () => void,
|
||||
}
|
||||
|
||||
export interface ChildProcess extends child_process.ChildProcess { }
|
||||
|
||||
export type Viewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
isMobile?: boolean;
|
||||
isLandscape?: boolean;
|
||||
hasTouch?: boolean;
|
||||
}
|
||||
|
||||
export class Connection extends RealConnection { }
|
||||
export class Target extends RealTarget { }
|
||||
}
|
||||
}
|
128
experimental/puppeteer-firefox/lib/firefox/helper.js
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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 {TimeoutError} = require('../Errors');
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class Helper {
|
||||
/**
|
||||
* @param {Function|string} fun
|
||||
* @param {!Array<*>} args
|
||||
* @return {string}
|
||||
*/
|
||||
static evaluationString(fun, ...args) {
|
||||
if (Helper.isString(fun)) {
|
||||
if (args.length !== 0)
|
||||
throw new Error('Cannot evaluate a string with arguments');
|
||||
return /** @type {string} */ (fun);
|
||||
}
|
||||
return `(${fun})(${args.map(serializeArgument).join(',')})`;
|
||||
|
||||
/**
|
||||
* @param {*} arg
|
||||
* @return {string}
|
||||
*/
|
||||
function serializeArgument(arg) {
|
||||
if (Object.is(arg, undefined))
|
||||
return 'undefined';
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
}
|
||||
|
||||
static promisify(nodeFunction) {
|
||||
function promisified(...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function callback(err, ...result) {
|
||||
if (err)
|
||||
return reject(err);
|
||||
if (result.length === 1)
|
||||
return resolve(result[0]);
|
||||
return resolve(result);
|
||||
}
|
||||
nodeFunction.call(null, ...args, callback);
|
||||
});
|
||||
}
|
||||
return promisified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Object} obj
|
||||
* @return {boolean}
|
||||
*/
|
||||
static isNumber(obj) {
|
||||
return typeof obj === 'number' || obj instanceof Number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Object} obj
|
||||
* @return {boolean}
|
||||
*/
|
||||
static isString(obj) {
|
||||
return typeof obj === 'string' || obj instanceof String;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!NodeJS.EventEmitter} emitter
|
||||
* @param {(string|symbol)} eventName
|
||||
* @param {function(?)} handler
|
||||
* @return {{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?)}}
|
||||
*/
|
||||
static addEventListener(emitter, eventName, handler) {
|
||||
emitter.on(eventName, handler);
|
||||
return { emitter, eventName, handler };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Array<{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?)}>} listeners
|
||||
*/
|
||||
static removeEventListeners(listeners) {
|
||||
for (const listener of listeners)
|
||||
listener.emitter.removeListener(listener.eventName, listener.handler);
|
||||
listeners.splice(0, listeners.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {!Promise<T>} promise
|
||||
* @param {string} taskName
|
||||
* @param {number} timeout
|
||||
* @return {!Promise<T>}
|
||||
*/
|
||||
static async waitWithTimeout(promise, taskName, timeout) {
|
||||
let reject;
|
||||
const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`);
|
||||
const timeoutPromise = new Promise((resolve, x) => reject = x);
|
||||
const timeoutTimer = setTimeout(() => reject(timeoutError), timeout);
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, errorText) {
|
||||
if (!condition)
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
helper: Helper,
|
||||
debugError: require('debug')(`puppeteer:error`),
|
||||
assert,
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
// Any comment. You must start the file with a single-line comment!
|
||||
pref("general.config.filename", "puppeteer.cfg");
|
||||
pref("general.config.obscure_value", 0);
|
199
experimental/puppeteer-firefox/misc/puppeteer.cfg
Normal file
@ -0,0 +1,199 @@
|
||||
// Any comment. You must start the file with a comment!
|
||||
|
||||
// Make sure Shield doesn't hit the network.
|
||||
// pref("app.normandy.api_url", "");
|
||||
pref("app.normandy.enabled", false);
|
||||
|
||||
// Disable updater
|
||||
pref("app.update.enabled", false);
|
||||
// make absolutely sure it is really off
|
||||
pref("app.update.auto", false);
|
||||
pref("app.update.mode", 0);
|
||||
pref("app.update.service.enabled", false);
|
||||
|
||||
// Dislabe newtabpage
|
||||
pref("browser.startup.homepage", 'about:blank');
|
||||
pref("browser.newtabpage.enabled", false);
|
||||
// Disable topstories
|
||||
pref("browser.newtabpage.activity-stream.feeds.section.topstories", false);
|
||||
|
||||
// Increase the APZ content response timeout in tests to 1 minute.
|
||||
// This is to accommodate the fact that test environments tends to be
|
||||
// slower than production environments (with the b2g emulator being
|
||||
// the slowest of them all), resulting in the production timeout value
|
||||
// sometimes being exceeded and causing false-positive test failures.
|
||||
//
|
||||
// (bug 1176798, bug 1177018, bug 1210465)
|
||||
pref("apz.content_response_timeout", 60000);
|
||||
|
||||
// Indicate that the download panel has been shown once so that
|
||||
// whichever download test runs first doesn't show the popup
|
||||
// inconsistently.
|
||||
pref("browser.download.panel.shown", true);
|
||||
|
||||
// Background thumbnails in particular cause grief, and disabling
|
||||
// thumbnails in general cannot hurt
|
||||
pref("browser.pagethumbnails.capturing_disabled", true);
|
||||
|
||||
// Disable safebrowsing components.
|
||||
pref("browser.safebrowsing.blockedURIs.enabled", false);
|
||||
pref("browser.safebrowsing.downloads.enabled", false);
|
||||
pref("browser.safebrowsing.passwords.enabled", false);
|
||||
pref("browser.safebrowsing.malware.enabled", false);
|
||||
pref("browser.safebrowsing.phishing.enabled", false);
|
||||
|
||||
// Disable updates to search engines.
|
||||
pref("browser.search.update", false);
|
||||
|
||||
// Do not restore the last open set of tabs if the browser has crashed
|
||||
pref("browser.sessionstore.resume_from_crash", false);
|
||||
|
||||
// Don't check for the default web browser during startup.
|
||||
pref("browser.shell.checkDefaultBrowser", false);
|
||||
|
||||
// Do not redirect user when a milstone upgrade of Firefox is detected
|
||||
pref("browser.startup.homepage_override.mstone", "ignore");
|
||||
|
||||
// Disable browser animations (tabs, fullscreen, sliding alerts)
|
||||
pref("toolkit.cosmeticAnimations.enabled", false);
|
||||
|
||||
// Do not close the window when the last tab gets closed
|
||||
pref("browser.tabs.closeWindowWithLastTab", false);
|
||||
|
||||
// Do not allow background tabs to be zombified on Android, otherwise for
|
||||
// tests that open additional tabs, the test harness tab itself might get
|
||||
// unloaded
|
||||
pref("browser.tabs.disableBackgroundZombification", false);
|
||||
|
||||
// Do not warn when closing all open tabs
|
||||
pref("browser.tabs.warnOnClose", false);
|
||||
|
||||
// Do not warn when closing all other open tabs
|
||||
pref("browser.tabs.warnOnCloseOtherTabs", false);
|
||||
|
||||
// Do not warn when multiple tabs will be opened
|
||||
pref("browser.tabs.warnOnOpen", false);
|
||||
|
||||
// Disable first run splash page on Windows 10
|
||||
pref("browser.usedOnWindows10.introURL", "");
|
||||
|
||||
// Disable the UI tour.
|
||||
//
|
||||
// Should be set in profile.
|
||||
pref("browser.uitour.enabled", false);
|
||||
|
||||
// Turn off search suggestions in the location bar so as not to trigger
|
||||
// network connections.
|
||||
pref("browser.urlbar.suggest.searches", false);
|
||||
|
||||
// Do not warn on quitting Firefox
|
||||
pref("browser.warnOnQuit", false);
|
||||
|
||||
// Do not show datareporting policy notifications which can
|
||||
// interfere with tests
|
||||
pref(
|
||||
"datareporting.healthreport.documentServerURI",
|
||||
"http://%(server)s/dummy/healthreport/",
|
||||
);
|
||||
pref("datareporting.healthreport.logging.consoleEnabled", false);
|
||||
pref("datareporting.healthreport.service.enabled", false);
|
||||
pref("datareporting.healthreport.service.firstRun", false);
|
||||
pref("datareporting.healthreport.uploadEnabled", false);
|
||||
pref("datareporting.policy.dataSubmissionEnabled", false);
|
||||
pref("datareporting.policy.dataSubmissionPolicyAccepted", false);
|
||||
pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
|
||||
|
||||
// Automatically unload beforeunload alerts
|
||||
pref("dom.disable_beforeunload", true);
|
||||
|
||||
// Disable popup-blocker
|
||||
pref("dom.disable_open_during_load", false);
|
||||
|
||||
// Disable the ProcessHangMonitor
|
||||
pref("dom.ipc.reportProcessHangs", false);
|
||||
|
||||
// Disable slow script dialogues
|
||||
pref("dom.max_chrome_script_run_time", 0);
|
||||
pref("dom.max_script_run_time", 0);
|
||||
|
||||
// Only load extensions from the application and user profile
|
||||
// AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
|
||||
pref("extensions.autoDisableScopes", 0);
|
||||
pref("extensions.enabledScopes", 5);
|
||||
|
||||
// Disable metadata caching for installed add-ons by default
|
||||
pref("extensions.getAddons.cache.enabled", false);
|
||||
|
||||
// Disable installing any distribution extensions or add-ons.
|
||||
pref("extensions.installDistroAddons", false);
|
||||
|
||||
// Turn off extension updates so they do not bother tests
|
||||
pref("extensions.update.enabled", false);
|
||||
pref("extensions.update.notifyUser", false);
|
||||
|
||||
// Make sure opening about:addons will not hit the network
|
||||
pref(
|
||||
"extensions.webservice.discoverURL",
|
||||
"http://%(server)s/dummy/discoveryURL",
|
||||
);
|
||||
|
||||
// Allow the application to have focus even it runs in the background
|
||||
pref("focusmanager.testmode", true);
|
||||
|
||||
// Disable useragent updates
|
||||
pref("general.useragent.updates.enabled", false);
|
||||
|
||||
// Always use network provider for geolocation tests so we bypass the
|
||||
// macOS dialog raised by the corelocation provider
|
||||
pref("geo.provider.testing", true);
|
||||
|
||||
// Do not scan Wifi
|
||||
pref("geo.wifi.scan", false);
|
||||
|
||||
// Show chrome errors and warnings in the error console
|
||||
pref("javascript.options.showInConsole", true);
|
||||
|
||||
// Do not prompt with long usernames or passwords in URLs
|
||||
pref("network.http.phishy-userpass-length", 255);
|
||||
|
||||
// Do not prompt for temporary redirects
|
||||
pref("network.http.prompt-temp-redirect", false);
|
||||
|
||||
// Disable speculative connections so they are not reported as leaking
|
||||
// when they are hanging around
|
||||
pref("network.http.speculative-parallel-limit", 0);
|
||||
|
||||
// Do not automatically switch between offline and online
|
||||
pref("network.manage-offline-status", false);
|
||||
|
||||
// Make sure SNTP requests do not hit the network
|
||||
pref("network.sntp.pools", "%(server)s");
|
||||
|
||||
// Local documents have access to all other local documents,
|
||||
// including directory listings
|
||||
pref("security.fileuri.strict_origin_policy", false);
|
||||
|
||||
// Tests do not wait for the notification button security delay
|
||||
pref("security.notification_enable_delay", 0);
|
||||
|
||||
// Ensure blocklist updates do not hit the network
|
||||
pref("services.settings.server", "http://%(server)s/dummy/blocklist/");
|
||||
|
||||
// Do not automatically fill sign-in forms with known usernames and
|
||||
// passwords
|
||||
pref("signon.autofillForms", false);
|
||||
|
||||
// Disable password capture, so that tests that include forms are not
|
||||
// influenced by the presence of the persistent doorhanger notification
|
||||
pref("signon.rememberSignons", false);
|
||||
|
||||
// Disable first-run welcome page
|
||||
pref("startup.homepage_welcome_url", "about:blank");
|
||||
pref("startup.homepage_welcome_url.additional", "");
|
||||
|
||||
// Prevent starting into safe mode after application crashes
|
||||
pref("toolkit.startup.max_resumed_crashes", -1);
|
||||
lockPref("toolkit.crashreporter.enabled", false);
|
||||
|
||||
// Disable crash reporter.
|
||||
Components.classes["@mozilla.org/toolkit/crash-reporter;1"].getService(Components.interfaces.nsICrashReporter).submitReports = false;
|
53
experimental/puppeteer-firefox/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "puppeteer-firefox",
|
||||
"version": "0.4.0",
|
||||
"description": "Puppeteer API for Firefox",
|
||||
"main": "index.js",
|
||||
"repository": "github:GoogleChrome/puppeteer",
|
||||
"engines": {
|
||||
"node": ">=8.9.4"
|
||||
},
|
||||
"puppeteer": {
|
||||
"firefox_revision": "e5fdeac984d4f966caafcdbc9b14da7a7f73fbed"
|
||||
},
|
||||
"scripts": {
|
||||
"install": "node install.js",
|
||||
"unit": "node test/test.js",
|
||||
"funit": "cross-env DUMPIO=1 PRODUCT=firefox node test/test.js",
|
||||
"cunit": "cross-env PRODUCT=chromium node test/test.js",
|
||||
"tsc": "tsc -p ."
|
||||
},
|
||||
"author": "The Chromium Authors",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0",
|
||||
"extract-zip": "^1.6.6",
|
||||
"https-proxy-agent": "^2.2.1",
|
||||
"mime": "^2.0.3",
|
||||
"progress": "^2.0.1",
|
||||
"proxy-from-env": "^1.0.0",
|
||||
"rimraf": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer": "^1.11.0",
|
||||
"@pptr/testrunner": "^0.5.0",
|
||||
"@pptr/testserver": "^0.5.0",
|
||||
"@types/debug": "0.0.31",
|
||||
"@types/extract-zip": "^1.6.2",
|
||||
"@types/mime": "^2.0.0",
|
||||
"@types/node": "^8.10.34",
|
||||
"@types/rimraf": "^2.0.2",
|
||||
"@types/ws": "^6.0.1",
|
||||
"commonmark": "^0.28.1",
|
||||
"cross-env": "^5.0.5",
|
||||
"eslint": "^5.9.0",
|
||||
"esprima": "^4.0.0",
|
||||
"jpeg-js": "^0.3.4",
|
||||
"minimist": "^1.2.0",
|
||||
"ncp": "^2.0.0",
|
||||
"pixelmatch": "^4.0.2",
|
||||
"pngjs": "^3.3.3",
|
||||
"text-diff": "^1.0.1",
|
||||
"typescript": "3.1.6"
|
||||
}
|
||||
}
|
1
experimental/puppeteer-firefox/test/assets/csp.html
Normal file
@ -0,0 +1 @@
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
|
12
experimental/puppeteer-firefox/test/assets/detect-touch.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Detect Touch Test</title>
|
||||
<script src='modernizr.js'></script>
|
||||
</head>
|
||||
<body style="font-size:30vmin">
|
||||
<script>
|
||||
document.body.textContent = Modernizr.touchevents ? 'YES' : 'NO';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
BIN
experimental/puppeteer-firefox/test/assets/digits/0.png
Normal file
After Width: | Height: | Size: 434 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/1.png
Normal file
After Width: | Height: | Size: 346 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/2.png
Normal file
After Width: | Height: | Size: 413 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/3.png
Normal file
After Width: | Height: | Size: 434 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/4.png
Normal file
After Width: | Height: | Size: 403 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/5.png
Normal file
After Width: | Height: | Size: 422 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/6.png
Normal file
After Width: | Height: | Size: 445 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/7.png
Normal file
After Width: | Height: | Size: 387 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/8.png
Normal file
After Width: | Height: | Size: 447 B |
BIN
experimental/puppeteer-firefox/test/assets/digits/9.png
Normal file
After Width: | Height: | Size: 437 B |
15
experimental/puppeteer-firefox/test/assets/error.html
Normal file
@ -0,0 +1,15 @@
|
||||
<script>
|
||||
a();
|
||||
|
||||
function a() {
|
||||
b();
|
||||
}
|
||||
|
||||
function b() {
|
||||
c();
|
||||
}
|
||||
|
||||
function c() {
|
||||
throw new Error('Fancy error!');
|
||||
}
|
||||
</script>
|
@ -0,0 +1,2 @@
|
||||
import num from './es6module.js';
|
||||
window.__es6injected = num;
|
@ -0,0 +1 @@
|
||||
export default 42;
|
@ -0,0 +1,2 @@
|
||||
import num from './es6/es6module.js';
|
||||
window.__es6injected = num;
|
@ -0,0 +1,8 @@
|
||||
<link rel='stylesheet' href='./style.css'>
|
||||
<script src='./script.js' type='text/javascript'></script>
|
||||
<style>
|
||||
div {
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
<div>Hi, I'm frame</div>
|
@ -0,0 +1,8 @@
|
||||
<frameset>
|
||||
<frameset>
|
||||
<frame src='./frame.html'></frame>
|
||||
<frame src='about:blank'></frame>
|
||||
</frameset>
|
||||
<frame src='/empty.html'></frame>
|
||||
<frame></frame>
|
||||
</frameset>
|
@ -0,0 +1,25 @@
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body iframe {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
::-webkit-scrollbar{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
async function attachFrame(frameId, url) {
|
||||
var frame = document.createElement('iframe');
|
||||
frame.src = url;
|
||||
frame.id = frameId;
|
||||
document.body.appendChild(frame);
|
||||
await new Promise(x => frame.onload = x);
|
||||
return 'kazakh';
|
||||
}
|
||||
</script>
|
||||
<iframe src='./two-frames.html' name='2frames'></iframe>
|
||||
<iframe src='./frame.html' name='aframe'></iframe>
|
@ -0,0 +1 @@
|
||||
<iframe src='./frame.html'></iframe>
|
@ -0,0 +1 @@
|
||||
console.log('Cheers!');
|
@ -0,0 +1,3 @@
|
||||
div {
|
||||
color: blue;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body iframe {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
</style>
|
||||
<iframe src='./frame.html' name='uno'></iframe>
|
||||
<iframe src='./frame.html' name='dos'></iframe>
|
@ -0,0 +1,3 @@
|
||||
<script>
|
||||
var globalVar = 123;
|
||||
</script>
|
52
experimental/puppeteer-firefox/test/assets/grid.html
Normal file
@ -0,0 +1,52 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function generatePalette(amount) {
|
||||
var result = [];
|
||||
var hueStep = 360 / amount;
|
||||
for (var i = 0; i < amount; ++i)
|
||||
result.push('hsl(' + (hueStep * i) + ', 100%, 90%)');
|
||||
return result;
|
||||
}
|
||||
|
||||
var palette = generatePalette(100);
|
||||
for (var i = 0; i < 200; ++i) {
|
||||
var box = document.createElement('div');
|
||||
box.classList.add('box');
|
||||
box.style.setProperty('background-color', palette[i % palette.length]);
|
||||
var x = i;
|
||||
do {
|
||||
var digit = x % 10;
|
||||
x = (x / 10)|0;
|
||||
var img = document.createElement('img');
|
||||
img.src = `./digits/${digit}.png`;
|
||||
box.insertBefore(img, box.firstChild);
|
||||
} while (x);
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
font-family: arial;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid darkgray;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,2 @@
|
||||
window.__injected = 42;
|
||||
window.__injectedError = new Error('hi');
|
@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: red;
|
||||
}
|
16
experimental/puppeteer-firefox/test/assets/input/button.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Button test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="mouse-helper.js"></script>
|
||||
<button onclick="clicked();">Click target</button>
|
||||
<script>
|
||||
window.result = 'Was not clicked';
|
||||
function clicked() {
|
||||
result = 'Clicked';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Selection Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<label for="agree">Remember Me</label>
|
||||
<input id="agree" type="checkbox">
|
||||
<script>
|
||||
window.result = {
|
||||
check: null,
|
||||
events: [],
|
||||
};
|
||||
|
||||
let checkbox = document.querySelector('input');
|
||||
|
||||
const events = [
|
||||
'change',
|
||||
'click',
|
||||
'dblclick',
|
||||
'input',
|
||||
'mousedown',
|
||||
'mouseenter',
|
||||
'mouseleave',
|
||||
'mousemove',
|
||||
'mouseout',
|
||||
'mouseover',
|
||||
'mouseup',
|
||||
];
|
||||
|
||||
for (let event of events) {
|
||||
checkbox.addEventListener(event, () => {
|
||||
if (['change', 'click', 'dblclick', 'input'].includes(event) === true) {
|
||||
result.check = checkbox.checked;
|
||||
}
|
||||
|
||||
result.events.push(event);
|
||||
}, false);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Keyboard test</title>
|
||||
</head>
|
||||
<body>
|
||||
<textarea></textarea>
|
||||
<script>
|
||||
window.result = "";
|
||||
let textarea = document.querySelector('textarea');
|
||||
textarea.focus();
|
||||
textarea.addEventListener('keydown', event => {
|
||||
log('Keydown:', event.key, event.code, event.which, modifiers(event));
|
||||
});
|
||||
textarea.addEventListener('keypress', event => {
|
||||
log('Keypress:', event.key, event.code, event.which, event.charCode, modifiers(event));
|
||||
});
|
||||
textarea.addEventListener('keyup', event => {
|
||||
log('Keyup:', event.key, event.code, event.which, modifiers(event));
|
||||
});
|
||||
function modifiers(event) {
|
||||
let m = [];
|
||||
if (event.altKey)
|
||||
m.push('Alt')
|
||||
if (event.ctrlKey)
|
||||
m.push('Control');
|
||||
if (event.metaKey)
|
||||
m.push('Meta')
|
||||
if (event.shiftKey)
|
||||
m.push('Shift')
|
||||
return '[' + m.join(' ') + ']';
|
||||
}
|
||||
function log(...args) {
|
||||
console.log.apply(console, args);
|
||||
result += args.join(' ') + '\n';
|
||||
}
|
||||
function getResult() {
|
||||
let temp = result.trim();
|
||||
result = "";
|
||||
return temp;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Scrollable test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src='mouse-helper.js'></script>
|
||||
<script>
|
||||
for (let i = 0; i < 100; i++) {
|
||||
let button = document.createElement('button');
|
||||
button.textContent = i + ': not clicked';
|
||||
button.id = 'button-' + i;
|
||||
button.onclick = () => button.textContent = 'clicked';
|
||||
button.oncontextmenu = event => {
|
||||
event.preventDefault();
|
||||
button.textContent = 'context menu';
|
||||
}
|
||||
document.body.appendChild(button);
|
||||
document.body.appendChild(document.createElement('br'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|